深入理解TCP连接管理

461 阅读19分钟

不知道你有没有被问到过下面的问题?

TCP是怎么建立连接的?

为什么TCP连接需要三次握手?

TCP连接的三次握手分别都携带了哪些信息?又交换了哪些信息?

连接断开为什么需要四次挥手?

... ...

有意思的是,虽然TCP连接管理在面试中出现的频率非常高,但在实际工作中几乎用不到。比如3次握手并不需要你手动写一个握手包,在C/C++中你只需要调用connect()函数就可以了。而关闭一个连接也只需要调用close()或者shutdown()函数。似乎,除了应付面试根本就没有学习的理由。但,真的是这样的吗?

当你着手排查一个生产网络问题时,netstat出来一堆FIN-XXX状态的连接信息,不由得小鹿乱撞。胡乱一顿骚操作居然给整好了,但怎么解决的,自己可能也说不清楚。网络协议就像是空气,平时似乎没觉得有多重要。但是,某天突然被困在电梯里,憋得快窒息的时候才会想起它的重要性。

三次握手

在计算机技术中很多时候都能在现实中找到它“原型”,TCP协议的三次握手也可以在现实中找到它的“原型”。比如,你想约隔壁班的小芳去看电影。你首先得打招呼,“你好,小芳。我是隔班的小伟”。然后小芳礼貌性的也会回答你,“你好,小伟”。接着,你说:“我有个事想对你说”。

完成了上面的对话,接着就可以说正事了,这时候小芳可以主动发起对话,比如:“你有什么事?”。但小伟也可以接着说:“我想约你看电影”。

当然,如果你向小芳打招呼,人家根本不搭理你,这个对话就没法建立了,这时候你可以厚着脸皮再向她打招呼,直到她答应为止。但也有可能她已经有男朋友了,直接就把你拒绝了。你心想,没事,只要锄头挥得好,没有墙角挖不倒!

当你和小芳很熟悉了,再约看电影可能就不需要再打招呼了,你可以直接说:“咱们晚上去看电影吧”。这就是TCP协议的Fast Open技术。

好了,三次握手就讲完啦~~

哈哈~~ 皮一下

上面的情景比较形象的说明了TCP的三次握手。但真实的TCP三次握手比这复杂一些,而且整个过程比较古板(计算机就像是一个古板的糟老头,它只能按照事先约定好的规则死板的去执行。所以,计算机的厉害并不是它真的有多么聪明,而是制定规则的人聪明)。

下面是经典的TCP三次握手:

图片

从这张图中,我们可以找出三次握手的关键步骤如下:

第1步,Client端发起连接,将TCP头中的SYN置为1,同时告诉对方自己期望下一个数据段从哪里开始,这个字段就是Seq Num,也就是序列号。然后自己进入到SYN-SENT状态。

第2步,Server端收到数据段从accept()函数返回,开始回复Client端,将SYN和ACK位置1,发送一个Ack Num和Seq Num,其中Ack Num设置为Client发过来的Seq Num加1, Seq Num是告诉Client自己期望收到的下一个数据段开始的位置。发送出去之后,自己状态从LISTEN变成SYN-RECEIVED。

第3步,Client收到了Server端的回复之后,将ACK位置为1,Ack Num设置为收到的Server端的Seq Num + 1,将数据段发送出去,然后自己的状态变为ESTABLISHED,这个状态表示连接已建立,可以正常收发数据了。

第4步,Server端收到Client端最后一个ACK之后,将状态置为ESTABLISHED状态,表示可以正常收发数据了。

至此,TCP的三次握手就完成了,但这中间还有很多问题还没有搞明白。比如,ACK、SYN是怎么置为1的,它们又是怎么排列的呢?下面我们把视角切换到每一次握手中数据的详细格式。

TCP报文格式

TCP报文格式中包含了固定的20字节的头信息(没有Option的情况下),剩下的才是具体的数据段。TCP头格式如下图:

图片

上面这张图,非常详细地说明了TCP头的格式,但这里面包含了很多知识点,这里挑一些比较有意思的展开说一下。

1. 为什么端口号最大只能是65535?

其中Source Port和Destination Port都占2字节,也就是16位,所以最大能表示的端口号是2^16=65536,由于0号端口有特殊用途不能使用,实际最大端口号是65536-1 = 65535。所以,当别人问到为什么端口号最大只能是65535?你就知道了,这个并不是操作系统的限制,而是TCP协议的限制。

2. TCP的Option到底是怎么传输的?

Option并没有包含在20字节的TCP头中,并且还可以选择性的不传,或者传多个。那么,如何确定Option的位置呢?在TCP头中有一个4位Offset字段,这个字段用来表示TCP的头加上Option之后的长度,要注意的是,这里的Offset单位是4字节。最大可以表示15 * 4 = 60字节,这是TCP头加上Option之后最大的长度,而Option最大长度=60-20字节的头,也就是40字节。

3. TCP都有哪些Option?

类型总长度(字节)数据说明
0--选项列表末尾标识
1--无意义,用于32位对齐
24MSS值握手时发送端告知可以接收最大报文
33窗口移位指明最大窗口扩展后的大小
42-表明支持SACK选择性确认中间报文
5可变确认报文选择性确认窗口中间的Segments
810时间戳更精准的计算RTT,解决PAWS问题
143校验和算法双方认可后,可使用新的校验和算法
15可变校验和16位校验和放不下,放在这里
34可变FOCTFO中Cookie

可以看到,TCP的头是比较复杂的,包含了很多的信息,而这些信息是保证TCP能正常运行的基础。接下来,我们来分析一下,TCP三次握手的时候每次TCP头都是什么样的。

第一次,Client端发送SYN, 此时SYN被置为1,同时发送了自己期望下次接收到数据的起始位置序列号。如下:

图片

第二次,Server端对Client进行ACK,将ACK和SYN置为1,确认序列号=客户端的序列号+1,序列号为自己期望下次接收数据的起始位置。如下:

图片

第三次,Client端对Server端的ACK,将ACK置为1,确认序列号=Server端序列号+1,如下:

图片

TCP为什么需要三次握手呢?

由于TCP是全双工协议(可以同时发送和接收),所以双方都要确保对方的收和发都是可用的。第一次握手,当Server收到SYN之后,Server端可以确定Client的发是可用的;第二次握手,Client收到ACK之后,Client端可以确定Server端的发和收都是可用的;第三次握手,Server端收到ACK,可以确定Client的收是可用的。至此,双方通过三次握手试探出了对方的收和发都是可用的。而三次握手是最佳的策略,少一次无法实现双方对收和发状态的确认,多一次又显得多余。

另一种说法是,由于信道不可靠,通信双方需要就某个问题达成一致,无论消息中包含什么值,三次通信是理论上的最小值。

所以,现在你知道为什么TCP要有三次握手了吧。

接收窗口

由于每台机器的配置、所处的网络环境不一样,导致网络传输效率不一致。整个网络传输不能以传输效率高的那台机器为准,因为效率低的那台机器会因为过于繁忙而应付不过来,甚至宕机。所以,TCP协议实现了限流算法,其底层数据结构就是耳熟能详的滑动窗口。这块内容可以回顾另一篇文章“为什么说TCP是可靠的传输协议”。

滑动窗口得以正常运行,需要双方先亮明底牌,告诉对方自已的能力上限,也就是接收窗口。在TCP的三次握手期间,除了序列号之外,还要告诉对方自己接收窗口的大小。这就是TCP头中窗口大小字段的意义。 

很多时候我们会听说TCP有发送窗口和拥塞窗口,当前机器的发送窗口其实可以理解为对端的接收窗口。而拥塞窗口是将视角切换到整个网络环境中通过拥塞算法找到窗口最小的那个设备的发送/接收窗口。而实际发送窗口=min(拥塞窗口, 发送/接收窗口)。

非预期的网络连接

有时候,网络建立连接并不像我们预想的那样发生。比如,你去约小芳看电影并不一定就是你先向小芳打招呼。也可能是她也想约你看电影,你们同时向对方打招呼。就像下面这样:

图片

TCP也想到了这一点,解决方法就是使用一个数据结构把对方发起请求的信息以及当前机器用来应对这个请求的信息给记录下来。这样,不管对方什么时候发起连接都先去本地找一找,看看有没有对方的信息。如果找到了就看进行到哪一步了,然后决定接下来应该怎么办。

TCP保存连接信息的数据结构叫作TCP(Transmission Control Block),它会记录连接的源端口、目标端口、目的IP、序列号、应答序列号、窗口大小、TCP状态等等信息。

TCP连接的扭转过程

我们知道,现在的计算机,都可以同时处理很多的连接。这么多的连接进来计算机又是如何处理的呢?

目前主流操作系统的网络协议栈,在TCP连接的过程中会使用两个队列来维护连接信息。一个队列是SYN队列,也就是前面Client第一次发的SYN就会进入到这个队列。第二个是ACCEPT队列,双方建立好连接之后会进入到这个队列,服务端从accept()返回的套接字就是从这个队列里取出来的。如下图:

图片

这看起来搞复杂了啊,为什么不直接用一个队列呢?其实这么做主要解决的问题是安全。假如有一个别有用心的人,发送大量的SYN包,如果只有一个队列,那这个队列很快就被填满了,背后的应用程序很快就扛不住了。另一方面是为了性能,对于还没建立好的连接,应用程序并不关心,如果放到一个队列里,应用程序至少要取出来判断一次,造成不必要的开销。而且判断完如果不是已经建立好的连接又该如何处理呢?

如何提升性能

TCP建立连接需要经历三次网络传输,网络传输是非常耗时的,对于长连接来讲这也许不是什么问题,但对于短连接来讲这个开销可就大了,比如HTTP协议,每次发送请求都要重新建立一次连接。

经过对TCP三次握手过程的分析,不难想到。优化连接的性能,可以从两个方面入手。第一,想办法减少网络传输次数。第二,通过缓存的方式减少本地频繁构建连接所需的数据结构。

Fast Open

正如其名,就是快速建立连接。当然,第一次还是要通过完整的三次握手来建立连接。当连接建立完了之后。我们就想,既然连接信息还在,如果第二次再连接的时候是不是不用完整的三次握手就可以把连接建立起来呢?答案是可以的。在建立连接的时候,Server端会生成一个Cookie返回给Client端,如下图中右边的部分:

图片

从图中,我们看到Fast Open的核心就是Cookie,Client再次建立连接的时候直接带上SYN+Cookie+Data,当Server端收到之后,通过Cookie还原出连接,这时在回给Client的时候除了ACK和SYN还可以带上数据。

在这个过程中,相当于是跳过了三次握手,直接进行数据传输。但这个功能是需要配置的,在linux中有一个配置项net.ipv4.tcp_fastopen,一共有4个配置项,如下:

0关闭
1作为客户端时可以使用TFO
2作为服务端是可以使用TFO
3无论作为客户端还是服务端都可以使用TFO

注:上面TFO是TCP Fast Open的缩写。

在Linux中,还可以通过一组配置来控制SYN的行为,比如下面的配置:

net.core.netdev_max_backlog    接收自网卡、但未被内核协议栈处理的报文队列长度net.ipv4.tcp_max_syn_backlog    SYNC_RECVD状态连接的最大个数net.ipv4.tcp_abort_on_overflow

超出处理能力,对新来的SYN直接回包RST,丢弃连接

TCP SYN Cookies

前面我们说当SYN队列满后,新的SYN就进不来了,有时候我们的机器还能够处理更多的请求,但由于SYN队列满了,大量的请求被挡在了外面,这个时候就可以使用TCP SYN Cookies来缓解这个问题。它的原理是当请求进来的时候,不进入SYN队列,直接计算出一个Cookie返回给Client,然后Client就可以带着这个Cookie和服务端接着完成剩下的工作了,如果三次握手没完成就继续三次握手,如果握手已经完成,就可以直接传数据了。Linux提供了一个配置项用来开启SYN Cookies:

net.ipv4.tcp_syncookies=1

这个配置项默认是关闭的,这是因为SYN Cookies本身是有缺陷的,由于cookie占用Option空间,导致部分TCP可选功能失效,例如扩充窗口、时间戳等。

TCP_DEFFER_ACCEPT

有些时候,我们建立了连接可能并没有立马发送数据。但网络协议栈会在三次握手完成之后忠实的调用accept()函数立马返回,但更好的方式应该是有数据传输的时候再返回,这样可以减少一次内核态到用户态的切换,这就是TCP_DEFFER_ACCEPT技术,但这个在平时似乎很少用到,知道有这个技术就可以了。

KeepAlive

TCP协议的默认心跳机制通过下面一级配置来表达:

net.ipv4.tcp_keepalive_time=7200  发送心跳周期  net.ipv4.tcp_keepalive_intvl=75     探测包发送间隔 

net.ipv4.tcp_keepalive_probes=9    探测包重试次数

可以看到,TCP默认的心跳周期是2个小时,这个对于当前很多业务场景几乎是不可用的。当然,也可以去修改Linux的参数来调整。

目前更主流的做法是业务代码自己实现心跳,基原理也很简单,定义好心跳包的数据格式和协议,然后定时发送。

校验和

校验和是为了解决数据传输前和接收端接收到数据的过程中不会被篡改,其实现的原理是对关键头部数据(12字节)+TCP数据执行校验和计算。校验和实际是违背了分层原则,因为参与校验数据还包含了IP层的数据,比如源IP和目地IP地址。

图片

TCP的校验和占4字节,如果大小不够会被放在Option中,而在Option中还可以指定校验和算法。Option取值是14和15,这里可以再回顾一下Option那张表。

四次挥手

四次挥手和三次握手一样,在面试中被问到的机率太高了,但实际上在工作中也几乎是很少用到,甚至用不到。四次挥手的过程如下:

图片

为了方便描述,我们假设发起关闭的一方是Client,被关闭一方是Server。

第1步,一开始两端的状态都是ESTABLISHED,Client发起关闭,将TCP头中的FIN置为1,然后自己进入到FIN-WAIT1的状态。第2步,Server收到FIN,回复Client,将ACK位置为1,自己进入到CLOSE-WAIT状态。第3步,Client收到ACK,状态置为FIN-WAIT2.第4步,Server端处理完网络协议栈里的Buffer,将TCP头中的FIN置为1发送给Client,然后进入到LAST-ACK状态。第5步,Client收到FIN,将ACK置为1发给Server,自己进入TIME-WAIT状态,2MSL之后状态置为CLOSED。

第6步,Server收到ACK,状态立马置为CLOSED,整个四次挥手过程完成。

四次挥手没有涉及到序列号、接收窗口、各种Option所以比三次握手相对要简单一些,但其中还是有一些需要特别注意的地方。

2MSL的Time Wait有什么危害?

我们要先搞明白为什么会存在最后的Time Wait,假设没有2MSL的Time Wait会怎么样?

如果Server端没有收到最后的那个ACK,而Client又没有Time Wait,就直接关闭了。然后,Server端过了一段时间没收到ACK,它的超时机制就又会重新发送FIN。此时,Client端已经关闭了,根本收到不到FIN,也没法发送ACK,Server端就会多次重发。更糟糕的是,如果此时同样的端口号在Client端和别的机器又建立了新的连接,此时收到了Server端发来的FIN,又该怎么处理?

所以,最后的2MSL的Time Wait的主要目的是帮助Server端正常的退出,是非常有必要的。

默认情况下,MSL的值定义如下:

#define TCP_TIMEWAIT_LEN(60*HZ)

也就是发起关闭一端需要60秒之后网络套接字才得以被释放(要注意,在某些版本中也被设置为30s、1min、2min)。如果系统60秒之内大量关闭和创建接连,最终套接字会被耗尽。更糟糕的是,如果刚好有一个一样的端口又和没有关闭的连接建立了新的连接,但2MSL之后这个套接子莫名其妙的又关闭了,就会产生错误。

解决Time Wait大概有以下几种方法:

1.设置Time Wait连接数据阈值

net.ipv4.tcp_max_tw_buckets = 1800

如果time_wait状态的连接超过这个值,就会强制重置所有time_wait连接的状态。但很明显,这种方式可能会有误伤,所以一般不建议。

2.修改MSL的值,重新编译内核

可以设置TCP_TIMEWAIT_LEN这个宏的值,来改变MSL的值,但这种方式需要重新编译内核,而且就算时间设置得比较短也不能完全避免上面的问题

3.使用setsockopt()函数

setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, opt_len);

上面是通过setsockopt来设置套接字属性,其中,SO_REUSEADDR表示的是可重用处于TIME-WAIT状态的套接字。

不知道你有没有遇到过,有时候我们明明把程序退出了,但是我们再运行的时候老是提示我们端口已经被占用了,再过一会运行又可以了。这就是由于Time Wait导致的。所以,一般在写网络程序的时候,都会使用setsockopt()函数将套接字设置为可重用。

TCP状态机

TCP协议连接的整个生命周期中,一共有11种状态,它们分别是:CLOSED、LISTEN、SYN_SENT、SYN_RECEIVED、ESTABLISHED、CLOSE_WAIT、LAST_ACK、FIN_WAIT1、FIN_WAIT2、CLOSING、TIME_WAIT。

乍一看有点多,但如果我们分阶段来分一下类其实就看差没那么复杂了。

三次握手阶段

SYN_SENT          Client发送SYN之后的状态

SYN_RECEIVED   Server收到SYN之后的状态

LISTEN               Server在收到SYN之前的状态

ESTABLISHED      三次握手完成之后的状态

四次挥手阶段

CLOSE_WAIT  Server收到FIN之后

LAST_ACK     Server发送FIN之后

FIN_WAIT       Client 发送FIN之后

FIN_EAIT2       Client 收到 Server的ACK之后

TIME_WAIT      Client ACK之后

其它

CLOSED    四次挥手完成之后

CLOSING   调用Close()之后,Close()执行完成之前

以我自己的经验来看,这11种状态死记硬背是记不住的,越记越迷糊。只有真正理解了三次握手和四次挥手之后,自然而然就记住了。比如,对于Client的四次挥手,它既要标记自己已经发送了FIN,又要标记是否收到了Server端的ACK。所以,有两个FIN的状态,一个是发送FIN之后的FIN_WAIT1状态,另一个是收到ACK之后的FIN_WAIT2。再比如,Server端收到FIN之后,由于还要把本地网络协议栈的Buffer处理完,但又不想Client误会产生超时触发重发。所以,它索性就先发一个ACK然后再慢慢处理本地的Buffer,这个时候双方已经初步达成共识了,马上要关闭了,但又还没真正关闭,所以就是CLOSE-WAIT。

只有理解了背后的原理,然后将各个状态放到不同的情景中才会印象深刻。下面是一张TCP各个状态扭转图,也叫TCP状态机:

图片

通过这张图可以更直观的理解各个状态之间的扭转。实际上,前面所有铺垫都是为了要搞明这张图,当搞明白了这张图之后,工作中遇到的很多网络的问题,就不至于像无头的苍蝇找不到解决问题的切入口。