WebServer 02
解决了几个问题:
1.如何唯一确定一个TCP连接
2.为什么不能是两次握手
3.如何确保序列号不重复
4.三次握手丢失了某一次会发生什么
5.四次挥手的过程
6.四次挥手丢失了某一次会发生什么
接着01的TCP
如何唯一确定一个TCP连接?
TCP四元组可以唯一地确定一个连接,四元组指的是:源地址、源端口、目标地址、目标端口。服务器可能在不同端口监听,客户端也可能用不同端口连接,所以一定是四元组才能唯一确定一个连接,不能只有源地址与目标地址
昨天学习了TCP头格式,我们知道TCP头格式只有源端口和目标端口(16位)这两个字段,没有源地址与目标地址。源地址与目标地址的字段(32位)是在IP头部中
❓一个服务端监听了一个端口,它的TCP的最大连接数是多少?
答:最大TCP连接数=客户端IP数 × 客户端端口数
这个“客户端”并不只是指一个具体的计算机,而是表示即将接受服务的计算机群体。现在想要求TCP最大连接数。服务端通常在一个固定的端口上监听,过来进行三次握手的客户端,可能是同一个计算机(IP相同)但使用不同的端口,也可能是不同的计算机(IP不相同)。如果假设每台计算机的可用端口数相同,那么TCP的最大连接数就是(IP数-1)× 可用端口数,而IP数=计算机数量,那太多了,所以-1可以省略,所以TCP的最大连接数是客户端IP数量 × 客户端端口数。但是,这是建立在每台计算机的可用端口数相同,或者近似相同的情况下。但事实是,端口数并不统一,不同的客户端操作系统、不同的网络环境会影响到它实际可用的临时端口范围。所以,这个公式应该被理解为一个组合数学上的可能空间,而不是一个可实际累加的精确数值
对于IPv4,客户端的IP数最多为2^32,客户端端口数最多为2^16,即服务端单机最大TCP连接数约为2^48
但这只是个理论上限,服务端最大并发TCP连接数远不能达到理论上限。一方面,每个TCP连接都是一个文件,如果连接过多,文件描述符会被占满。另一方面,每个TCP连接都要占用一定内存,操作系统的内存有限
为什么是三次握手而不是两次、四次?
我们来分析一下在网络阻塞/客户端宕机情形下可能出现的一种情况:
如果是两次握手:
客户端发送SYN报文(seq=200),但这个SYN报文被网络阻塞了。接着客户端重新发送新的SYN报文(seq=100)。但旧的SYN报文比新的SYN报文先到达。服务端收到SYN报文后,进入ESTABLISHED状态,返回一个SYN-ACK报文(seq=400,ack=201),客户端发现ack应当为101,判断为历史连接,那么客户端发送RST报文断开连接。一段时间后,新的SYN报文才到达,这次才能正确握手。可见,如果采用两次握手建立TCP连接,那么服务端在向客户端发送数据前,并没有阻止历史连接,导致服务端建立了一个历史连接,又白白发送了数据,浪费了服务端的资源。因此,在服务端建立连接之前就应该阻止历史连接。
那三次握手是如何阻止历史连接的?我们也分析一下:
还是用上面的数据。服务端收到SYN报文后,进入SYN-RCVD状态而不是直接进入ESTABLISHED状态!也就是说,这时候连接还没有真正建立!接着同上,服务端收到RST后,由于其状态为SYN-RCVD,所以服务端的应用程序完全不知道有这个连接的存在,它可以轻松地撤销这个半连接,恢复到CLOSED状态,应用程序没有受到任何影响,没有数据被发送。也就是说,三次握手相比于两次握手的优势在于,它有一个中间状态SYN-RCVD,可以阻止历史连接,这样可以让客户端先发送RST报文,避免了服务端已经进入ESTABLISHED状态后客户端才发送RST报文。
❓追问:为什么服务端进入ESTABLISHED状态就会浪费资源?
三次握手情况下:
服务端收到SYN报文,发送SYN-ACK报文,此时其内核会为此连接创建一个TCB(传输控制块,存储了一个TCP连接的状态信息,01中最后提及了),并将其状态设为SYN-RCVD.
此时服务端应用程序阻塞在accept(),它对这个新来的连接一无所知,accept()队列为空。
服务端收到ACK报文后,会将对应TCB状态改为ESTABLISHED,并将这个已建立连接的TCB放入“已完成连接队列”中,这个操作会唤醒阻塞在accept()上的应用程序。应用程序的accept()返回,得到一个代表着个连接的Socket文件描述符。从这一刻起,这个应用程序才真正获得了这个连接,它可以调用read()或write()进行向客户端读取/发送数据。
也就是说,在三次握手的情况下,历史连接的RST会在服务端状态为SYN-RCVD时被接收,此时内核只需要丢掉这个半连接的TCB,应用程序对此毫不知情,依旧阻塞在accept()上。
两次握手情况下:
内核收到SYN,发送SYN-ACK,立即创建TCB并将其状态设为ESTABLISHED,然后立即将此TCB放入“已完成连接队列”,阻塞在accept()上的应用程序被立刻唤醒。应用程序从accept()返回,拿到Socket文件描述符,进入其业务逻辑。比如说在聊天室中,它可能会立即向客户端发送“欢迎来到聊天室!”或者从数据库查询数据并返回。这就是不必要的资源浪费。这些数据最终被客户端丢弃,服务端的CPU时间、内存带宽、网络带宽彻底浪费。
可见,ESTABLISHED状态是内核向应用程序移交连接所有权的触发器。三次握手的优势在于,它将可能收到RST的危险期严格限制在了SYN-RCVD状态下,从而保护了应用程序,使其永远不会接触到一个无效的连接。而在两次握手的情况下,服务端没有中间状态给客户端阻止历史连接,导致它可能建立一个历史连接,进而导致资源浪费。
另外,TCP协议的通信双方,都必须维护一个序列号(TCP头格式的内容之一),它的作用是:
1.接收方可以去除重复的数据
2.接收方可以根据序列号按序接收
3.判断发出去的数据包中,哪些是对方已经收到的(通过ACK报文中的序列号知道)
解释一下上面三点:
我们将TCP通信比喻成你(发送方)向一位管理员邮寄一本手稿。你们之间只能通过不靠谱的邮政系统(相当于不靠谱的网络)进行邮寄,因此邮件可能丢失、重复、乱序。序列号就是你写在每个信封上的唯一页码编号
1.接收方可以去除重复的数据
你寄出了第100页(序列号=100),但因为网络问题导致这页被复制了,管理员先后收到了两封都写着 ”第100页” 的信,它会将自己先收到的第100页放进档案夹,将后收到的第100页直接丢弃。
这是因为接收方的TCP栈维护着一个变量,记录着它期望收到的下一个字节的序列号。如果收到一个序列号小于这个期望值的包,就说明这是一个重复包,直接丢弃。
也就是说,发送方的操作系统内核严格保证了其发出的字节流序列号是单调递增的。例如当应用程序调用send()或write()发送一段数据时,比如“Hello”,那么内核就会将这5个字节的数据标记为序列号x到x+4,发送完成后,内核会立即将本地的“下一个序列号”指针向前移动5位,更新为x+5,接下来要发送的第一个字节,其序列号就是x+5.实际上在每个TCP连接中的TCB字段里,有一个snd_nxt
变量,它指示了即将要发送的下一个数据的序列号。还有一个变量snd_una
,这是已发送但还未收到对方ACK确认的第一个字节的序列号。当构建一个要发送的数据包时,内核从snd_nxt
中取出当前值作为该包数据的起始序列号。数据包发出后,根据数据包中数据的长度,更新snd_nxt
=snd_nxt
+数据长度。当收到对方的ACK,确认对方已经收到了序列号N之前的所有数据,就更新snd_una
=N.
如果不是单调递增,那么就无法去重(无法判断是新数据还是旧数据)、无法排序(序列号回退、乱跳)、确认机制失效(ack=N的含义是N之前的所有数据都收到,这依赖于单调性)。
2.接收方可以根据序列号按序接收
你寄出的顺序是第101页、102页、103页,但由于网络路由不同,管理员收到的顺序是第102页、101页、103页
管理员先收到102页,他发现他期望的是第101页,所以它不会把第102页直接交给读者(应用程序),他会把第102页临时存放在一边(缓冲区)。接着他收到第101页,这正是他期望的,于是他将第101页放入档案夹,然后检查临时存放区,发现102页也在,于是把102页页放入档案夹,接着收到103页,直接放入档案夹。
通过排序确保了提交给上层应用的数据永远是顺序正确的字节流
3.发送方可以知道哪些数据已被对方收到
管理员每收到一页都会给你一封确认信,信的内容是:你寄来的截止到103页的所有页我都已经收到,接下来我期望收到第104页。这可以让发送方确认数据交付,同时也让发送方能够触发重传机制。
也就是说,去重和排序是从接收者角度出发,解决了网络带来的重复和乱序问题。确认接收则是从发送方角度出发,解决网络带来的丢失问题。
两次握手只保证了客户端的初始序列号能被服务端成功接收,没办法保证服务端的初始序列号也能被确认接收。也就是说,如果只是两次握手,那么服务端是不知道自己能否与客户端正常通信的。
假设我们只有两次握手:
第一次握手:客户端发送SYN,序列号为client_isn
第二次握手:服务端收到SYN,进入ESTABLISHED状态,发送SYN-ACK,其中包含对客户端序列号的确认ack=client_isn+1,服务端自己的初始序列号:seq=server_isn
假设客户端到服务端的路径是畅通的,但服务端到客户端的路径有问题。那么客户端永远都收不到SYN-ACK,因此它认为连接未建立,会停留在SYN-SENT状态,并会重传自己的SYN,但服务端对此一无所知,它会认为连接已经建立,服务端内核通知上层应用程序(例如从accept()返回)。应用程序会开始使用这个链接,例如调用send()向客户端发送数据,但同样也会在S->C路径上丢失。这会造成资源空耗,在连接超时被重置之前,服务器的内存、端口资源、CPU周期都被这个无效链接白白占用。
而三次握手还是那个道理,它有一个SYN-RCVD状态作为缓冲。只要没有收到客户端发来的ACK,那么服务端就不会进入ESTABLISHED状态。等待ACK连接超时后,会简单地清除这个半连接。也就是说,三次握手确保了双方的初始序列号同步。
总结:两次握手无法防止历史连接的建立,且无法可靠地同步双方序列号,会造成资源浪费。
三次握手就已经可以可靠地建立连接,因此无需四次握手。
❓追问:既然建立TCP连接的序列号是单调递增的,那上限在哪里?既然有上限,又如何规避序列号重复?
答:TCP的序列号是一个32位的无符号整数,即它的取值范围是:0到2^31-1,即0到4294967295.
我们假设某个TCP连接的初始序列号ISN=4294967290.现在客户端开始大量发送数据,序列号快速递增,很快就到达了上限。到达上限4294967295后,就从0开始。此时,一个在网络上延迟的、序列号较小的旧数据包突然到达了接收端,那么接收端会认为这个延迟的旧包是一个合法的新数据包,因为它落在了当前期望的序列号范围之内,这就导致了数据混乱。而且,如果网络速度够快,传输大量数据时序列号回绕的时间就会变短,那么就很容易出现序列号相同的情况。这个问题通过TCP时间戳解决。如果两个TCP连接的序列号相同,但它们的时间戳也大概率时不相同的。也就是说,客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个相同四元组的连接接收,又引入了时间戳机制,从而基本避免了历史报文被接收的问题。
但时间戳也是有上限的,理论上时间戳也有可能相同。
如果一个旧连接的时间戳已经接近最大值,由于回绕,一个新连接的时间戳很小,那么如果只是单纯比较时间戳大小,就同样会发生数据混乱。
解决方法是:不直接比较时间戳的绝对大小,而是检查它们的差值是否在一个合理的范围内。这套规则基于一个关键假设:数据包在网络中的最大生存时间远远小于时间戳的回绕周期。事实上,这个假设在绝大多数情况下是成立的。
简单来说,其逻辑如下:
1.设置一个值PAWS,一般来说是24天,这个值远小于回绕周期50天,但又远大于数据包最大生存时间(一般是2分钟)。
2.假设新数据包的时间戳为new,旧数据包时间戳为prev,那么delta=new-prev.
如果delta>0,那么说明是新包,接受。
如果delta<0,且|delta|>PAWS,这意味着new比prev小很多,又因为我们假设网络延迟不会超过24天,那么唯一的可能就只剩下时间戳回绕。所以new对应的数据包实际上是新包,因此接受。
如果delta<0,且|delta|<=PAWS,这意味着差值在合理的网络延迟范围内,所以时间戳为new的那个数据包一定是旧包,丢弃。
讨论完这个问题之后,我们就可以明白为什么每次建立TCP连接时的初始序列号要不一样了。如果初始序列号一样,接收方就有可能接收到一个本不应该接收的数据包。这就好比一个陌生人由于填错了收件人的地址姓名电话,导致你收到了一个本不应该由你收下的快递一样。
总结:每次建立连接时的初始化序列号随机产生,在加上时间戳机制与合理的比较算法,很大程度上避免了历史报文被下一个相同四元组的连接接收的问题。
第一次握手丢失了,会发生什么?
第一次握手丢失,意味着客户端在进入SYN-SENT状态后迟迟没有收到服务端的SYN-ACK报文,那么这会触发重传机制,并且重传的SYN报文的序列号是一样的。不同版本的操作系统设置的超时时间不同,而至于重发次数,我们可以查看:
cat /proc/sys/net/ipv4/tcp_syn_retries
通常每次超时的时间是上一次的2倍。
如果到达了等待上限,服务端依然没有回应ACK,那么客户端就不再发送SYN包,然后断开TCP连接。
第二次握手丢失了,会发生什么?
服务端收到客户端的第一次握手后,就会回SYN-ACK报文给客户端,然后进入SYN-RCVD状态。
由于第二次握手丢失,客户端会觉得自己的SYN报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传SYN报文。又因为服务端收不到ACK报文(第三次握手),所以它也会触发超时重传机制,重传SYN-ACK报文。
同样,SYN-ACK报文的最大重传次数也可以查看:
cat /proc/sys/net/ipv4/tcp_synack_retries
也就是说,如果第二次握手丢失了,那么客户端会重传SYN报文(直到收到来自服务端的SYN-ACK),服务端会重传SYN-ACK报文(直到收到来自客户端的ACK)。
第三次握手丢失了,会发生什么?
客户端收到服务端的SYN-ACK报文后,会给服务端回一个ACK报文,也就是第三次握手,此时客户端进入ESTABLISHED状态。
由于第三次握手丢失,服务端始终收不到客户端的ACK报文,会触发超时重传机制,重传SYN-ACK报文,直到收到第三次握手。
可见,ACK报文是不会重传的。
TCP四次挥手的过程
比喻:
A:我说完了。(第一次挥手)
B:好的,我知道你说完了。(第二次挥手)
B:...(可能B还要再说几句)
B:我也说完了。(第三次挥手)
A:好的,我知道你也说完了。(第四次挥手)
第一次挥手
发起者:客户端
动作:当客户端的数据都发送完毕后,它会发送一个TCP报文。这个报文将FIN标志位设置为1,包含了当前的序列号seq=u.随后客户端进入FIN-WAIT-1状态,这表示客户端已经没有数据需要发送了,但它仍然可以接收来自服务器的数据。
第二次挥手
发起者:服务端
动作:服务端收到客户端的FIN报文后,会立即做出回应。回应的报文将ACK标志位设置为1,其中ack=u+1(因为第一次挥手消耗了一个序列号),同时报文包含了自己的序列号seq=v.服务端进入CLOSE-WAIT状态,客户端收到这个ACK报文后,进入FIN-WAIT-2状态。
此时,连接处于半关闭状态。客户端不能再发送数据,但服务端仍然可以向客户端发送数据。
第三次挥手
发起者:服务端
动作:服务端发送第二个报文来关闭自己这个方向的连接。这个报文将FIN标志位设置为1,其中包含了报文的序列号seq=w,报文的确认号ack仍然为u+1,因为没有收到客户端的新数据。此后,服务端进入LAST-ACK状态。
第四次挥手
发起者:客户端
动作:客户端收到服务端的FIN报文后,也做出回应。回应的报文将ACK标志位设置为1,回应的确认号ack=w+1,回应的序列号seq=u+1.客户端发送ACK后,进入TIME-WAIT状态,服务端收到这个ACK后,进入CLOSED状态,连接正式关闭。客户端在TIME-WAIT状态会等到2MSL(两倍的最大报文段生存时间)的时间后才进入CLOSED状态。
TIME-WAIT存在的理由:
1.确保最后一个ACK报文能到达服务端。如果这个ACK丢失,那么服务端在超时后会重传它的FIN报文,这样客户端就能收到重传的FIN,可以再次发送ACK,确保连接正常关闭。如果没有TIME_WAIT状态,而是在发完最后一次ACK报文后直接进入CLOSED状态,那么倘若这个ACK报文丢失了,服务端重传FIN报文,此时客户端已经进入关闭状态,收到FIN报文后会回RST报文,服务端收到RST报文后将其解释为一个错误,这并不优雅。
2.防止历史连接中的数据被后面相同四元组的连接错误接收。等待2MSL时间,可以确保本次连接所产生的所有报文都在网络中消亡了,这样再建立新的连接就不会收到旧的、延迟的报文,避免了数据混乱。
第一次挥手丢失了,会发生什么?
服务端是什么都不知道的,对于客户端来说,它觉得自己已经告知对方自己讲完了,但对方迟迟不回复。因此会触发超时重传机制,重发次数由tcp_orphan_retries
参数控制。如果到达了等待的上限,还是没有收到第二次挥手,那么直接进入到CLOSED状态。
第二次挥手丢失了,会发生什么?
对于客户端来说,和第一次挥手丢失没有区别,它依然不知道对方是否知道自己说完了。因此客户端会重传FIN报文。到达上限后,就会断开连接。
第三次挥手丢失了,会发生什么?
服务端收到客户端的FIN报文后,会回复ACK报文,并且进入CLOSE-WAIT状态,即等待应用进程调用close函数关闭连接。调用close函数后,内核发出FIN报文,服务端进入LAST-ACK状态,等待客户端返回ACK报文来确认连接关闭。
如果第三次挥手丢失,那么它就收不到客户端的ACK报文,就会重发FIN报文。重发次数同样由tcp_orphan_retries
控制。如果达到了上限,那么服务端就会断开连接。客户端通过close函数关闭连接,处于FIN_WAIT_2状态的时间有限,如果在这个有限的时间tcp_fin_timeout
内没有收到服务端的第三次挥手,那么客户端会断开连接。
第四次挥手丢失了,会发生什么?
由于服务端没有收到ACK报文,因此它会重发FIN报文,重发次数仍然由tcp_orphan_retries
控制。如果达到上限后依然没有收到客户端的第四次挥手,那么服务端就会断开连接。对于客户端,它在收到第三次挥手后会进入TIME_WAIT状态,开启时长为2MSL的定时器。如果中途再次收到FIN报文,那么就会重置定时器。等待超过2MSL时长后,客户端就会断开连接。