TCP ,丫的终于来了!

AGC性能分析

tcp 是一种面向连接的单播协议,在 tcp 中,并不存在多播、广播的这种行为,因为 tcp 报文段中能明确发送方和接受方的 ip 地址。

[[394208]]

之前的文章一直在聊各种网络协议,那么从这篇文章开始,我就会和你聊一聊关于 tcp 协议的种种特征,比如 tcp 连接管理(也是这篇文章主要讨论的)、tcp 超时和重传、tcp 拥塞控制、tcp 数据流和窗口管理。

tcp 是一种面向连接的单播协议,在 tcp 中,并不存在多播、广播的这种行为,因为 tcp 报文段中能明确发送方和接受方的 ip 地址。

在发送数据前,相互通信的双方(即发送方和接受方)需要建立一条连接,在发送数据后,通信双方需要断开连接,这就是 tcp 连接的建立和终止。

tcp 连接的建立和终止

如果你看过我之前写的关于网络层的一篇文章,你应该知道 tcp 的基本元素有四个:即发送方的 ip 地址、发送方的端口号、接收方的 ip 地址、接收方的端口号。而每一方的 ip + 端口号都可以看作是一个套接字,套接字能够被唯一标示。套接字就相当于是门,出了这个门,就要进行数据传输了。

tcp 的连接建立 -> 终止总共分为三个阶段

下面我们所讨论的重点也是集中在这三个层面。

下图是一个非常典型的 tcp 连接的建立和关闭过程,其中不包括数据传输的部分。

tcp 建立连接 - 三次握手

服务端进程准备好接收来自外部的 tcp 连接,一般情况下是调用 bind、listen、socket 三个函数完成。这种打开方式被认为是 被动打开(passive open)。然后服务端进程处于 listen 状态,等待客户端连接请求。

客户端通过 connect 发起主动打开(active open),向服务器发出连接请求,请求中首部同步位 syn = 1,同时选择一个初始序号 sequence ,简写 seq = x。syn 报文段不允许携带数据,只消耗一个序号。此时,客户端进入 syn-send 状态。

服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 syn 和 ack 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,tcp 服务器进入 syn-received(同步收到) 状态。

客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ack 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。tcp 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入 established (已连接) 状态

服务器收到客户的确认后,也进入 established 状态。

这是一个典型的三次握手过程,通过上面 3 个报文段就能够完成一个 tcp 连接的建立。三次握手的的目的不仅仅在于让通信双方知晓正在建立一个连接,也在于利用数据包中的选项字段来交换一些特殊信息,交换初始序列号。

一般首个发送 syn 报文的一方被认为是主动打开一个连接,而这一方通常也被称为客户端。而 syn 的接收方通常被称为服务端,它用于接收这个 syn,并发送下面的 syn,因此这种打开方式是被动打开。

tcp 建立一个连接需要三个报文段,释放一个连接却需要四个报文段。

tcp 断开连接 - 四次挥手数据

传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 established 状态,然后进入释放连接的过程。

tcp 断开连接需要历经的过程如下

客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 tcp 连接。客户端主机发送释放连接的报文段,报文段中首部 fin 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入 fin-wait-1(终止等待 1) 阶段。

服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ack = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入 close-wait(关闭等待) 状态。

客户端主机收到服务端主机的确认应答后,即进入 fin-wait-2(终止等待2) 的状态。等待客户端发出连接释放的报文段。

这时服务端主机会发出断开连接的报文段,报文段中 ack = 1,序列号 seq = v,ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了 last-ack(最后确认)的阶段。

客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ack = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = v + 1,然后进入到 time-wait(时间等待) 状态,请注意,这个时候 tcp 连接还没有释放。必须经过时间等待的设置,也就是 2msl 后,客户端才会进入 closed 状态,时间 msl 叫做最长报文段寿命(maximum segment lifetime)。

服务端主要收到了客户端的断开连接确认后,就会进入 closed 状态。因为服务端结束 tcp 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。

tcp 连接的任意一方都可以发起关闭操作,只不过通常情况下发起关闭连接操作一般都是客户端。然而,一些服务器比如 web 服务器在对请求作出相应后也会发起关闭连接的操作。tcp 协议规定通过发送一个 fin 报文来发起关闭操作。

所以综上所述,建立一个 tcp 连接需要三个报文段,而关闭一个 tcp 连接需要四个报文段。tcp 协议还支持一种半开启(half-open) 状态,虽然这种情况并不多见。

tcp 半开启

tcp 连接处于半开启的这种状态是因为连接的一方关闭或者终止了这个 tcp 连接却没有通知另一方,也就是说两个人正在微信聊天,cxuan 你下线了你不告诉我,我还在跟你侃八卦呢。此时就认为这条连接处于半开启状态。这种情况发生在通信中的一方处于主机崩溃的情况下,你 xxx 的,我电脑死机了我咋告诉你?只要处于半连接状态的一方不传输数据的话,那么是无法检测出来对方主机已经下线的。

另外一种处于半开启状态的原因是通信的一方关闭了主机电源 而不是正常关机。这种情况下会导致服务器上有很多半开启的 tcp 连接。

tcp 半关闭

既然 tcp 支持半开启操作,那么我们可以设想 tcp 也支持半关闭操作。同样的,tcp 半关闭也并不常见。tcp 的半关闭操作是指仅仅关闭数据流的一个传输方向。两个半关闭操作合在一起就能够关闭整个连接。在一般情况下,通信双方会通过应用程序互相发送 fin 报文段来结束连接,但是在 tcp 半关闭的情况下,应用程序会表明自己的想法:"我已经完成了数据的发送发送,并发送了一个 fin 报文段给对方,但是我依然希望接收来自对方的数据直到它发送一个 fin 报文段给我"。下面是一个 tcp 半关闭的示意图。

解释一下这个过程:

首先客户端主机和服务器主机一直在进行数据传输,一段时间后,客户端发起了 fin 报文,要求主动断开连接,服务器收到 fin 后,回应 ack ,由于此时发起半关闭的一方也就是客户端仍然希望服务器发送数据,所以服务器会继续发送数据,一段时间后服务器发送另外一条 fin 报文,在客户端收到 fin 报文回应 ack 给服务器后,断开连接。

tcp 的半关闭操作中,连接的一个方向被关闭,而另一个方向仍在传输数据直到它被关闭为止。只不过很少有应用程序使用这一特性。

同时打开和同时关闭

还有一种比较非常规的操作,这就是两个应用程序同时主动打开连接。虽然这种情况看起来不太可能,但是在特定的安排下却是有可能发生的。我们主要讲述这个过程。

通信双方在接收到来自对方的 syn 之前会首先发送一个 syn,这个场景还要求通信双方都知道对方的 ip 地址 + 端口号。

比如恋爱中的一对男女,他俩都同时说出了我爱你这个神圣的誓言,然后他俩对彼此的响应进行么么哒,这就是同时打开。

下面是同时打开的例子

如上图所示,通信双方都在收到对方报文前主动发送了 syn 报文,都在收到彼此的报文后回复了一个 ack 报文。

一个同时打开过程需要交换四个报文段,比普通的三次握手增加了一个,由于同时打开没有客户端和服务器一说,所以这里我用了通信双方来称呼。

像同时打开一样,同时关闭也是通信双方同时提出主动关闭请求,发送 fin 报文,下图显示了一个同时关闭的过程。

同时关闭过程中需要交换和正常关闭相同数量的报文段,只不过同时关闭不像四次挥手那样顺序进行,而是交叉进行的。

聊一聊初始序列号

也许是我上面图示或者文字描述的不专业,初始序列号它是有专业术语表示的,初始序列号的英文名称是initial sequence numbers (isn),所以我们上面表示的 seq = v,其实就表示的 isn。

在发送 syn 之前,通信双方会选择一个初始序列号。初始序列号是随机生成的,每一个 tcp 连接都会有一个不同的初始序列号。rfc 文档指出初始序列号是一个 32 位的计数器,每 4 us(微秒) + 1。因为每个 tcp 连接都是一个不同的实例,这么安排的目的就是为了防止出现序列号重叠的情况。

当一个 tcp 连接建立的过程中,只有正确的 tcp 四元组和正确的序列号才会被对方接收。这也反应了 tcp 报文段容易被伪造 的脆弱性,因为只要我伪造了一个相同的四元组和初始序列号就能够伪造 tcp 连接,从而打断 tcp 的正常连接,所以抵御这种攻击的一种方式就是使用初始序列号,另外一种方法就是加密序列号。

tcp 状态转换

我们上面聊到了三次握手和四次挥手,提到了一些关于 tcp 连接之间的状态转换,那么下面我就从头开始和你好好梳理一下这些状态之间的转换。

首先第一步,刚开始时服务器和客户端都处于 closed 状态,这时需要判断是主动打开还是被动打开,如果是主动打开,那么客户端向服务器发送 syn 报文,此时客户端处于 syn-send 状态,syn-send 表示发送连接请求后等待匹配的连接请求,服务器被动打开会处于 listen 状态,用于监听 syn 报文。如果客户端调用了 close 方法或者经过一段时间没有操作,就会重新变为 closed 状态,这一步转换图如下

这里有个疑问,为什么处于 listen 状态下的客户端还会发送 syn 变为 syn_sent 状态呢?

知乎看到了车小胖大佬的回答,这种情况可能出现在 ftp 中,listen -> syn_sent 是因为这个连接可能是由于服务器端的应用有数据发送给客户端所触发的,客户端被动接受连接,连接建立后,开始传输文件。也就是说,处于 listen 状态的服务器也是有可能发送 syn 报文的,只不过这种情况非常少见。

处于 syn_send 状态的服务器会接收 syn 并发送 syn 和 ack 转换成为 syn_rcvd 状态,同样的,处于 listen 状态的客户端也会接收 syn 并发送 syn 和 ack 转换为 syn_rcvd 状态。如果处于 syn_rcvd 状态的客户端收到 rst 就会变为 listen 状态。

这两张图一起看会比较好一些。

这里需要解释下什么是 rst

这里有一种情况是当主机收到 tcp 报文段后,其 ip 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 ip 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 rst 特殊报文段给客户端。

因此,当服务端发送一个 rst 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。

rst:(reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到 rst 位时候,通常发生了某些错误。

上面没有识别正确的 ip 端口是一种导致 rst 出现的情况,除此之外,rst 还可能由于请求超时、取消一个已存在的连接等出现。

位于 syn_rcvd 的服务器会接收 ack 报文,syn_send 的客户端会接收 syn 和 ack 报文,并发送 ack 报文,由此,客户端和服务器之间的连接就建立了。

这里还要注意一点,同时打开的状态我在上面没有刻意表示出来,实际上,在同时打开的情况下,它的状态变化是这样的。

为什么会是这样呢?因为你想,在同时打开的情况下,两端主机都发起 syn 报文,而主动发起 syn 的主机会处于 syn-send 状态,发送完成后,会等待接收 syn 和 ack , 在双方主机都发送了 syn + ack 后,双方都处于 syn-received(syn-rcvd) 状态,然后等待 syn + ack 的报文到达后,双方就会处于 established 状态,开始传输数据。

好了,到现在为止,我给你叙述了一下 tcp 连接建立过程中的状态转换,现在你可以泡一壶茶喝点水,等着数据传输了。

好了,现在水喝够了,这时候数据也传输完成了,数据传输完成后,这条 tcp 连接就可以断开了。

现在我们把时钟往前拨一下,调整到服务端处于 syn_rcvd 状态的时刻,因为刚收到了 syn 包并发送了 syn + ack 包,此时服务端很开心,但是这时,服务端应用进程关闭了,然后应用进程发了一个 fin 包,就会让服务器从 syn_rcvd -> fin_wait_1 状态。

然后把时钟调到现在,客户端和服务器现在已经传输完数据了 ,此时客户端发送了一条 fin 报文希望断开连接,此时客户端也会变为 fin_wait_1 状态,对于服务器来说,它接收到了 fin 报文段并回复了 ack 报文,就会从 established -> close_wait 状态。

位于 close_wait 状态的服务端会发送 fin 报文,然后把自己置于 last_ack 状态。处于 fin_wait_1 的客户端接收 ack 消息就会变为 fin_wait_2 状态。

这里需要先解释一下 closing 这个状态,fin_wait_1 -> closing 的转换比较特殊

closing 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送fin 报文后,按理来说是应该先收到(或同时收到)对方的 ack 报文,再收到对方的 fin 报文。但是 closing 状态表示你发送 fin 报文后,并没有收到对方的 ack 报文,反而却也收到了对方的 fin 报文。

什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方在同时关闭一个链接的话,那么就出现了同时发送 fin 报文的情况,也即会出现 closing 状态,表示双方都正在关闭连接。

fin_wait_2 状态的客户端接收服务端主机发送的 fin + ack 消息,并发送 ack 响应后,会变为 time_wait 状态。处于 close_wait 的客户端发送 fin 会处于 last_ack 状态。

这里不少图和博客虽然在图上画的是 fin + ack 报文后才会处于 last_ack 状态,但是描述的时候,一般通常只对于 fin 进行描述。也就是说 close_wait 发送 fin 才会处于 last_ack 状态。

所以这里 fin_wait_1 -> time_wait 的状态也就是接收 fin 和 ack 并发送 ack 之后,客户端处于的状态。

然后位于 closinig 状态的客户端这时候还有 ack 接收的话,会继续处于 time_wait 状态,可以看到,time_wait 状态相当于是客户端在关闭前的最后一个状态,它是一种主动关闭的状态;而 last_ack 是服务端在关闭前的最后一个状态,它是一种被动打开的状态。

上面有几个状态比较特殊,这里我们向西解释下。

time_wait 状态

通信双方建立 tcp 连接后,主动关闭连接的一方就会进入 time_wait 状态。time_wait 状态也称为 2msl 的等待状态。在这个状态下,tcp 将会等待最大段生存期(maximum segment lifetime, msl) 时间的两倍。

这里需要解释下 msl

msl 是 tcp 段期望的最大生存时间,也就是在网络中存在的最长时间。这个时间是有限制的,因为我们知道 tcp 是依靠 ip 数据段来进行传输的,ip 数据报中有 ttl 和跳数的字段,这两个字段决定了 ip 的生存时间,一般情况下,tcp 的最大生存时间是 2 分钟,不过这个数值是可以修改的,根据不同操作系统可以修改此值。

基于此,我们来探讨 time_wait 的状态。

当 tcp 执行一个主动关闭并发送最终的 ack 时,time_wait 应该以 2 * 最大生存时间存在,这样就能够让 tcp 重新发送最终的 ack 以避免出现丢失的情况。重新发送最终的 ack 并不是因为 tcp 重传了 ack,而是因为通信另一方重传了 fin,客户端经常回发送 fin,因为它需要 ack 的响应才能够关闭连接,如果生存时间超过了 2msl 的话,客户端就会发送 rst,使服务端出错。