三次握手和四次挥手可能出现的问题

前言

今天握手和挥手问题被面试官问到了。正好有点闲情,做个实验记录下加深自己对TCP的了解吧。

面试的题目:

  • 如果客户端想要去和服务端建立连接,首先需要进行三次握手。那么如果服务端机器宕机了会怎么样呢(三次握手没建立前)?
  • 如果客户端已经和服务端建立连接了,然后服务器上面的进程突然挂了,那么会怎么样呢?

PS,简单的握手和状态变化可以参考这篇文章,如果连最基本的都不了解还是先去通读吧。

握手

image-20200730203925159

握手就是这张图。然后我们开始折腾。

第一个包丢掉

如果客户端想要和服务器进行连接,那么要主动发送第一个包给服务器。如果不幸这个包在还没到达服务器就丢掉了,那么会发生什么呢?

首先跟服务器肯定一点关系都没有,因为它压根都不知道有包发给它了,它不需要做任何操作。

而客户端也不知道自己的包丢了,在它的视角里,它还傻傻得等着服务器给它回送ACK包呢。然后大约过了一段时间,它等急了,就会重新发送一个包,希望建立连接。不巧,这个包又丢了,那么一样的,客户端会再次间隔一段时间重新发送。总共尝试三次(不包含第一次发送的),三次间隔时间分别是5.8s、24s、48s(不同的资料对这一块的记录有所差异,另外有一说是1s,2s,4s,8s和16s,见下面的实验)。当然这个时间是比较久的,我们可以通过设置超时时间自己指定。

第二个包丢掉

如果客户端建立的第一个包成功送到了服务器,服务器接受之后给出了响应,于是就把第二个包发送了出去。但是不巧,这个包丢了。

那么客户端和之前的第一个包丢掉是一样的,因为它不可能知道到底是自己发包服务器没收到呢,还是服务器给自己发包丢了呢?

而服务器就和之前不一样了,它自己是知道自己已经发送了ACK的,期待能够收到第三个包。但是结果却是收不到,一段时间后,服务端也等急了,因为收不到第三个包。那么他也会重新发送这个ACK的包,但是时间没之前第一个包那么久,分别是间隔3s、6s、12s。你以为那么简单?别忘了,此时客户端也会同时继续向服务器端发送第一个包,服务器只要收到第一个包就会立刻回送第二个包。

第三个包丢掉

可以看到,第三个包一旦发出,客户端立即进入到ESTABLISHED状态,此时已经可以发送数据了。但是服务器收不到第三个包,也就是服务器一直处于SYN-REVD状态,等待这个第三个包的到来。那么就会有以下的情况:

  • 服务器发完第二个包之后一直没等到任何别的包,于是它会按照第二个包丢失的那样处理。
  • 服务器发完第二个包之后,没有收到第三个包,但是却收到了数据的包。这个就有点有意思了,因为如果从服务器的角度来说,自己没有收到第三个包,但是却收到了数据包,是可以推测出,客户端其实是发送了,只不过丢了这个结论的。那么它应该也进入ESTABLISHED这个状态。至于说为什么是应该呢,因为还有另外一种出于安全的考虑的不同实现:没有收到第三个包,一律发送RST,要求重新建立连接。

故意不发送的情况

上面的那些情况都是包在网络中不小心丢掉了,而这里的情况则是收到了包裹,却故意不发送,我们分析下故意不发送有什么情况:

  • 第一个包故意不发送。emmm 没这个必要….
  • 第二个包故意不发送。相当于服务器不想让你去连接,那也没什么影响,毕竟这种事情要双方同意的嘛。
  • 第三个包故意不发送。著名的SYN-FLOOD攻击就是了。

首先在受害者机器上抓包:tcpdump "tcp[tcpflags] & (tcp-syn) != 0" -w /tmp/net.cap

受害者服务器还可以通过查看是不是有很多的SYN_REVD状态的包来判断是不是遭到了攻击。

防御手段:

  • 适当缩小服务器重发包的次数。这个次数默认是五次,我们可以缩短到1次,如果一个客户端真的想和我们通信,他必定会再次发送包,所以不用担心对真正的客户端进行影响。
  • 使用SYN cookie技术,内核默认开启。
  • 增加backlog队列的长度(该队列用来存放还未发送ACK的包)
  • 防火墙。

连接中

连接中的时候,代表三次握手已经完成了,可能会出现服务器整个宕机了,或者是进程丢掉了,这里暂时不考虑客户端的问题。

服务器整体宕机

这个就是客户端发送了TCP的包,但是一直收不到ACK,就会不停重试,直到到了时间释放这个连接。

进程死亡

进程死亡其实和你试图去和一个根本不存在的端口进行通信是一样的情况。操作系统拿到包之后,需要交给监听指定端口的进程,但是如果操作系统发现这个端口压根就没人监听,那么它会直接回送一个RST包,客户端收到这个RST包之后,就会放弃连接,并且给应用程序回送一个错误。

服务器收到莫名其妙的包

直接RST就完事了。

参数设置

有了上面的经验,我们应该可以比较容易判断出是哪个包丢失了,出问题了,于是我特地去做了一些实验。

与一个不存在的机器建立连接

首先我随便在命令行里输入了一个ip地址,并且希望能够获取到监听在80端口的文件,结果:

image-20200807203410875

emmm 结果这台机器不仅存在,而且还提供了http服务,就很尴尬….


算了再随便换一台,试试看:

image-20200807203512502

emmm 从返回的结果来看,网络上是肯定有这么一台机器的,只不过人家没有监听80端口,所以操作系统给我们回送了一个RST包,结果就是Connection refused。


最后我终于找到了一台不存在的机器(即分配了该ip的机器下线了),并且成功用wget得到了测试结果:

image-20200807203714407

然后我同时也在这台机器上进行了抓包,我们来看看结果:

image-20200807203913457

红色框框框起来的是第一次wget,蓝色框框的是wget retrying之后的包,而且蓝色的没有截全。

从图中不难发现,确实是从1s,2s,4s,8s,16s,32s来递增的。最后一个包发出之后,应该是过了64s,没有收到ack,那么客户端(在本例中是wget)就放弃三次握手了,但是wget不死心,又发起了新的三次握手,可以从seq的变化中明显看到。

那么是哪个参数控制了SYN的重试次数呢?

image-20200807204303948

个人建议其实3次就差不多了,但是ubuntu 16默认的是6。

半连接攻击(SYN-FLOOD)

原理

一旦服务器接受到了一个SYN,就会在自己的内核的半连接队列中,放入这个半连接。如果成功建立连接,就从半连接队列中取出,放到全连接队列中。我们的后端程序只能从全连接队列中取数据。

半连接队列的长度可以这么看:

image-20200807204928491

可以看到默认是128。

与此对应的是全连接队列的长度:

image-20200807205217669

如果你需要修改半连接队列的长度,请务必同时把全连接队列的长度也改成至少和半连接队列一样大,因为大多数的半连接都会放到全连接中。

内核计算半连接队列的长度并不是直接查看tcp_max_syn_backlog来确定的,而是按照下面的规则来判断的:

  • tcp_max_syn_backlog如果大于min(somaxconn,backlog),那么就是2*min(somaxconn,backlog)的长度。
  • tcp_max_syn_backlog如果小于min(somaxconn,backlog),那么就是2*tcp_max_syn_backlog的长度。

而且实际中的半连接队列的长度,还要看内核的实现。不同内核的实现是不同的。

编程中遇到的backlog

我这边直接截取了jdk的源码:

image-20200807210338588

不知道你有没有好奇,参数中的backlog指的是什么呢?如果它比我们系统设置的大,会怎么样呢?

答案是显然的:这个参数明显指的是全连接队列,因为我们的应用程序只能从全连接队列中取。如果比内核设置的要大,当然是无效的啦。当然如果内核比它要大,那么以它为准——就是取两者之间较小的那个。

知名服务器中的backlog

找了一个nginx配置文件:

image-20200807210851246

可以看到它设置了backlog是20480。当然和上面的java一样,也是取内核参数和服务器设置的较小的那个为准。

防御

经过上面的分析,半连接攻击就是把你的半连接队列给你填满,那么最最简单也是最没用的防御措施就是调大这个参数233333。

除此之外,还可以通过启用tcp_syncookies来有效避免(默认是1,意味着当半连接放不下的时候启用):

image-20200807211201651

原理就是你SYN攻击不是要把我的半连接队列塞满么,那我满了之后我就不往半连接队列里面放,而是返给客户端一个值;当客户端发送ack包过来的时候,需要带上这个值,如果合法的话那么就建立连接。

第二个包丢失的情况

当服务器发送给客户端的syn+ack包丢失的时候,它当然会选择重发了,和上面一样,继续做实验来看看到底会发生什么。

这次我选择去网上找了一个syn-flood的攻击脚本,让它“攻击”(其实就是用来发送一个syn包)服务器,然后在服务器上观察结果:

image-20200807221743119

可以看到首先回送了一个syn+ack,然后过了1秒回送了第二个,再过了2秒又回送了一个。然后就没反应了。

这是因为服务器设置了只重复两次:

image-20200807221856871

相信聪明的你应该也猜到了如果设置了更多次数的话,就是不停按照乘以2的时间等下去。

第三个包丢失的情况

第三个包丢失的情况这里就不贴图了,口述一下。一旦客户端把第三个包发送出去,那么它就单独认为连接已经建立了。但是由于服务端收不到,此时服务端会一直发送第二个包,当然客户端也会一直回送第三个包。经过一段时间(我的机器上面是2次,见上),服务端收不到第三个包,那么就直接把连接从半连接队列中去掉,然后放弃这次连接。但是!客户端认为自己建立了连接,于是它就会发送数据包给服务端,但是服务端显然是不会去理睬客户端的。当客户端发现经过一定次数之后还是收不到ACK,那么TCP就认为网络出现了问题,此时会向应用层报告问题。这个次数可以通过cat /proc/sys/net/ipv4/tcp_retries2查看。

那么又有一个问题,如果客户端认为连接建立了,但是服务端压根就没这个连接,而客户端又不发送任何的数据,那么岂不是这个问题永远也发现不了了么?不是的,TCP也有自己的机制来确保能够发现——保活机制。如果过了一段时间都没有包的发送,那么就会间隔几秒(这个间隔时间不会递增,所有的间隔时间都是一样的)发送保活包(可以理解为游戏中的心跳包),只要N个包没收到,就认为连接没了,也会向上层的应用程序报告。

image-20200808132434298

这三个参数分别是间隔75秒发送一次心跳包,需要9次才判定失效。在7200秒(2小时)不发送任何TCP包才启动这个机制。所以理论上你需要等待两个多小时才能发现,这也太久了吧……

全连接队列满了怎么办?

那就直接丢弃呗。如果不想丢弃的话,可以向客户端回送RST包。

如果丢弃的话,那么客户端自然会重发丢失的包;而如果是RST,那么客户端就会收到Connection reset by peer。我们可以通过设置:

image-20200807223311808

0代表直接丢弃,而1则是代表发送RST包。如果我们的服务器只是短暂的繁忙,那么推荐直接丢掉,这样等空下来了(反正客户端会重新发)就可以处理了。

优化

服务端的优化

由于协议的规定,TCP的前两个包(SYN置位的包)不能发送数据,所以如果要传送HTTP数据,那么最少也要从第三个包开始,也就是必须要等待一个RTT时间。

那么我们可不可以稍微进行优化,减少这个时间呢?在Linux内核的不断优化下,产生了TCP FAST OPEN这个全新的功能,默认情况下是作为客户端打开的,作为服务端是关闭的,可以通过cat /proc/sys/net/ipv4/tcp_fastopen查看。

该机制的核心思路就是打破syn不能传递数据的桎梏:

  • 在首次建立连接的时候,客户端发送的syn会请求cookie,然后服务端会生成cookie,并且随着第二个包发还给客户端。接下来的步骤就和普通的TCP三次握手一样。
  • 显然如果只来一次,这个操作一点用都没有。但是当客户端第二次与服务器三次握手的时候,第一个包就会携带应用层的请求(如HTTP GET)和cookie,然后服务器验证cookie有效,先发送第二个包(SYN+ACK),然后立马发送数据给客户端,相当于我请求发出去就立马收到了响应。如果cookie无效么,那么就自然进行普通的握手喽。

客户端优化

我感觉客户端能做的优化,就是稍微减少syn发送重试次数,6次还是有点大….

挥手优化

不得不提的close和shutdown

这两个都可以关闭连接,但是有不小的差距。

  • close会将引用套接字的计数减一,如果发现是0了,直接暴力关闭。
  • shutdown就比较优雅了。
    • 如果是读关闭,那么如果你读取这个socket,会向你返回EOF;并且读缓冲区的所有数据都丢弃,未来如果有新的数据到达读缓冲区,ack照样发送,但是缓冲区的数据会直接丢弃。
    • 如果是写关闭,那么把发送缓冲区的内容全部发出去,然后发送FIN给对端。

优化

主动方可以通过内核发送一个RST包,这样连接会断开,非常暴力,但是不够安全。

正常的关闭需要由进程来发送FIN包断开连接。如果这个FIN包丢失了,那么肯定需要重传,经过上面的洗礼,你大概应该也猜到了套路了:

image-20200807231411277

结果居然是0?纳尼?我FIN丢失了难道不重发吗?这显然有悖常理。其实这里的0相当于8啦。如果我们发现主动方有很多的FIN_WAIT1的状态,说明它们的FIN都发出去了,但是没有收到ACK,此时我们可以稍稍降低这个值。一旦重发次数到了,那么就会直接关闭。

FIN包还有一个问题,就是当主动发送方如果缓冲区有数据,那么FIN包是发不出去的;同时如果接收方的接受窗口是0,那么你的FIN也是发不出去的。

如果进程调用了close()系统调用,那么会使接收区的信息无法被读取,同样无法发送数据。Linux为了防止这种连接数过多,就设置了一个最大的参数,具体可以通过cat /proc/sys/net/ipv4/tcp_max_orphans查看。如果系统中的孤儿连接超过了这个数,那么就不是四次挥手了,而是直接RST。

如果用了shutdown关闭了连接,那么可以理论上会发送一个FIN,并且很快收到ACK,此时就进入到了FIN_WAIT2阶段。但是如果你是close关闭的,反正你也已经接受不了数据了,所以Linux对它处于FIN_WAIT2的时间做了限制,可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看,默认60秒。这个时间也就是TIME_WAIT阶段的时间。

还有一些新奇的点:如果ack和Fin一并发送,那么四次挥手可以进化成三次挥手。如果双方同时发送FIN给对端,那么双方都认为自己是主动发起者,双方都会有TIME-WAIT阶段。

番外:最大传输速率

这个问题是字节跳动二面的时候面试官问我的,虽然算不得是握手挥手中的问题,但是记录一下也挺好。

首先接收方有一个接收窗口,内核接收到包之后,会放进内核的缓冲区中,此时缓冲区变小, 接收方的窗口就会变小。只有当进程把数据从内核缓冲区中读走之后,才会变大。

其次,由于接收方需要把自己的窗口大小告诉给发送方,而这个信息是通过TCP的包头传递的,很遗憾这个只有16位,即窗口大小最多也不过64kB。显然这个大小是不够的的,所以现在扩展到了30位,即1GB。通过cat /proc/sys/net/ipv4/tcp_window_scaling可以查看是否打开了窗口扩展机制。

然后,你这些个包都要经过路由器吧?那路由器肯定也有限制吧?路由器处理不过来的时候也是直接丢弃并且回送一个ICMP报文。

最后,你发送方同样也要受制于接收窗口,两者是约等于的关系。

查看发送方的缓冲区大小,通过cat /proc/sys/net/ipv4/tcp_wmem这个命令,我们可以查到三个数,分别代表了最小值,初始值和最大值,我这里是4096 16384 4194304,单位是字节。

同理也可以通过cat /proc/sys/net/ipv4/tcp_rmem来查看接收方缓冲区的动态参数,分别是4096 87380 6291456

TCP发送方的缓冲区的大小调节是自动的,而接收方的自动调节则需要自己控制,默认也是开启的:cat /proc/sys/net/ipv4/tcp_moderate_rcvbuf