Linux高性能服务器编程 2. TCP协议
2. TCP协议
传输层的协议主要有两个:TCP 和 UDP 协议。TCP 协议相对于 [UDP 协议](https://so.csdn.net/so/search?q=UDP 协议&spm=1001.2101.3001.7020)的主要特点是:面向连接、面向字节流和可靠传输。
使用 TCP 通信的双方都必须建立连接,并分配必要的内核资源。TCP 连接是全双工的,双方的数据读写可以通过同一个连接进行。TCP 连接是一对一的。
[TCP 协议](https://so.csdn.net/so/search?q=TCP 协议&spm=1001.2101.3001.7020)采用发送应答机制,发送端发送的每个 TCP 报文段都必须得到接收方的应答,才认为传输成功。且采用超时重传机制,发送方在发出一个 TCP 报文段后启动定时器,定时时间内未收到应答,将重发。
由于 TCP 报文段会被封装成 IP 数据报发送,而 IP 数据报到达接收端可能会乱序、重复,因此 TCP 协议还会对接收到的 TCP 报文段重排、整理,再交付给应用层。
UDP 协议则和 IP 协议一样,提供不可靠服务。
特点
- **面向连接:**TCP协议在传输数据前需要建立连接,确保数据传输的可靠性。例如,网页浏览需要在浏览器和服务器之间建立TCP连接。
- **全双工通信:**TCP连接建立后,双方可以同时发送和接收数据。例如,视频通话过程中,双方可以同时发送和接收音视频数据。
- **可靠性:**TCP协议通过确认应答、重传机制和序号保证数据不丢失、无差错、不重复和按序到达。例如,文件传输需要确保文件完整性,TCP协议可以保证这一点。
- **面向字节流:**TCP协议将数据视为连续的字节流进行传输。例如,下载文件时,TCP协议将文件数据视为连续的字节流,确保数据顺序接收。
TCP 头部结构
标志位的含义
标志位含义:
- URG Uregent:紧急位,URG=1,表示数据紧急,需要立即处理。
- ACK Acknowledgement:确认位,ACK=1,确认号才生效,用于确认已收到的数据。携带 ACK 标志的称为确认报文段。
- PSH Push:推送位,PSH=1,尽快将数据交付给应用层,而不是等待缓冲区满。
- RST Reset:重置位,RST=1,重新建立连接,用于异常情况下重置连接。携带 RST 标志的称为复位报文段。
- SYN Synchronization:同步位,SYN=1,表示连接请求报文,当SYN=1而ACK=0时,表明这是一个连接请求报文,若对方同意建立连接,则在相应报文中令SYN=1和ACK=1。携带 SYN 标志的称为同步报文段。
- FIN Finish:终止位,FIN=1,表示释放连接,用于正常关闭连接。携带 FIN 标志的称为结束报文段。
TCP 连接的建立和关闭
TCP 连接的端口称为套接字 (socket)。
socket = (主机 IP 地址, 端口号)
建立连接过程(三次握手)
建立连接前客户端、服务器都处于关闭状态(CLOSED
),直到客户端主动打开连接,服务器才被动打开连接(处于监听状态LISTEN
),等待接受客户端的请求。
- 第一次握手:将同步标志位设为1(
SYN=1)
,随机选择一个序号(seq=x
)。客户端进入SYN_SEND
状态。 - 第二次握手:服务器收到请求连接报文段后,若同意建立连接,则将同步标志位设为1(
SYN=1
),确认标记为设为1(ACK=1
),随机选择一个序号(seq=y
),确认收到的序号(ack=x+1
),服务器进入同步已接收状态(SYN_RCVD
)。 - 第三次握手:客户端收到确认报文段后,向服务器再次发出连接确认报文段,确认标记位设为1(
ACK=1
),序号(seq=x+1
),确认收到的序号(ack=y+1
)。客户端先进入ESTABLISHED
状态,服务端收到后同时进入。
为什么建立连接需要三次握手,两次行不行?
三次握手是为了确定客户端、服务器收发数据都正常。防止服务器接收了早已失效的连接请求,从而一直等待客户端请求,最终导致浪费资源。如果只有两次握手,就会出现,客户端发出的第一个连接请求段没有丢失,只是在某个网络节点长时间滞留,而客户端进入了超时重传,重新发送了一个连接请求,建立连接,传输完数据之后连接释放,而此时第一次发送的连接请求到达服务器,这是一个失效的连接请求,但是服务器不知道,正常确认连接,此时客户端已经关闭,服务器一直等待。所以两次不行。
- 第一次握手:客户端发送请求,此时服务器知道客户端发送数据正常。
- 第二次握手:服务器发送确认,此时客户端知道服务器接收、发送数据都正常。
- 第三次握手:客户端发送确认,此时服务器知道客户端接收数据正常。
如果已经建立连接,但是客户端突然出现故障怎么办?
TCP还设有一个保活计时器,Client端如果出现故障,Server端不能一直等下去,这样会浪费系统资源。每收到一次Client客户端的数据帧后,Server端都的保活计时器会复位。计时器的超时时间通常是设置为2小时,若2小时还没有收到Client端的任何数据帧,Server端就会发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文仍然没反应,Server端就认为Client端出了故障,接着就关闭连接。
释放连接过程(四次挥手)
- 第一次挥手:客户端(服务器也可以主动发,一般是客户端)向服务器发送一个断开连接请求报文,
FIN=1
、seq=u
。发送完成后,进入FIN_WAIT-1
(终止等待1)状态。这表示客户端没有业务数据要发给对方了,通知服务器自己要断开连接了。 - 第二次挥手:正常情况下,在收到客户端发送的FIN断开连接请求之后,服务器都会发送一个ACK响应报文,
ACK=1
、seq=v
、ack=u+1
。该报文的意思是,我同意你的断开连接请求,服务器进入CLOSEWAIT
(关闭等待)状态,此时TCP协议服务会通知高层的应用进程,对方已经没有数据要发送了,如果我方还有数据要发送,可以继续发。客户端进入FIN_WAIT-2
(终止等待状态2)。 - 第三次握手:在发送完ACK确认报文后,服务器还能继续发送业务数据,当服务器发送完数据后,或者
CLOSEWAIT
(关闭等待)截止后,服务器会主动发送断开连接请求,FIN=1
、ACK=1
、seq=w
、ack=u+1
。表示服务器也没有数据要发送了,然后进入LAST_ACK
(最后确认)状态。 - 第四次挥手:客户端在收到服务器的断开连接请求报文后,进行最后的确认,向服务器发送一个ACK确认报文,然后进入
TIME_WAIT
(超时等待状态),在等待2MSL的时间后,如果期间没有收到其他报文,则证明对方已正常关闭,主动断开方的连接最终关闭。
最后为什么要等待2MSL之后才关闭,为什么不收到对方关闭请求之后就关闭?
因为最后一次的确认报文有可能丢失,如果服务器超时等待没有收到确认报文,会在发一次,此时如果客户端直接断开连接,那服务器一直没得到响应,会一直发,浪费资源。2MSL对应于一次消息的来回(一个发送和一个回复)所需的最大时间。如果直到2MSL,主动断开方都没有再一次收到对方的报文(如FIN报文),则可以推断ACK已经被对方成功接收,此时,主动断开方将最终结束自己的TCP连接。
为什么关闭连接需要四次挥手,而建立连接只需要三次握手?
关闭连接时,被断开方在接收到对方的FIN断开连接请求报文时,很可能还有业务数据没发送完成,并不能马上断开连接,又不能对对方的断开连接请求置之不理,因为对方没有收到确认会认为断开连接请求在路上丢失,触发超时重传。所以只能先回复一个ACK确认报文,告诉对方,我知道你要断开连接了,但我有业务数据要发送,只能等待我所有的业务数据都发送完才能真正结束,在结束之后,我会给你发送FIN+ACK断开连接请求报文的。所以被断开方的确认报文需要分成两步,故需要四次挥手。
TCP 连接是全双工的,所以允许两个方向的数据传输被独立关闭,通信的一方可以发送结束报文段给对方,但允许继续接受来自对方的数据,直到对方也发送结束报文段,最终关闭连接。这种状态被称为半关闭状态。
TIME_WAIT 状态
从上图来看,客户端连接在收到服务器的结束报文段(FIN=1
. ACK=1
)后 ,并没有直接进入 CLOSED
状态,而是转移到了 TIME_WAIT
状态,并等待一段长为 2MSL(Maximum Segment Life, 报文段最大生存时间),才能完全关闭。MSL 是 TCP 报文段在网络中的最大生存时间。
TIME_WAIT
状态存在的原因:
- 可靠地终止 TCP 连接;
- 保证迟到的 TCP 报文段有足够的时间被识别并丢弃。
第一个原因很好理解。假设图中用于确认服务器结束报文段的TCP报文段丢失,那么服务器将重发结束报文段。因此客户端需要停留在某个状态以处理重复收到的结束报文段(即向服务器发送确认报文段)。否则,客户端将以复位报文段来回应服务器,服务器则认为这是一个错误。
在 Linux 系统上,一个 TCP 端口不能被同时打开多次(两次及以上)。当一个 TCP 连接处于 TIME_WAIT
状态时,我们将无法立即使用该连接占用着的端口来建立一个新连接。反过来思考,如果不存在TIME_WAIT
状态,则应用程序能够立即建立一个和刚关闭的连接相似的连接(这里说的相似,是指它们具有相同的 IP 地址和端口号)。这个新的、和原来相似的连接被称为原来的连接的化身(incarnation)。新的化身可能接收到属于原来的连接的、携带应用程序数据的 TCP 报文段,这显然是不应该发生的。
另外,因为TCP报文段的最大生存时间是 2MSL,坚持 2MSL 时间的TIMB_WAIT
状态能够确保网络上两个传输方向上尚未被接收到的、迟到的TCP报文段都已经消失(被中转路由器丢弃)。因此,一个连接的新的化身可以在2MSL时间之后安全地建立,而绝对不会接收到属于原来连接的应用程序数据,这就是TIME_WAIT
状态要持续2MSL时间的原因。
有时候我们希望避免TIME_WAIT
状态,因为当程序退出后,我们希望能够立即重启它。但由于处在TIME_WAIT状态的连接还占用着端口,程序将无法启动(直到2MSL超时时间结束)。
对客户端程序来说,我们通常不用担心上面描述的重启问题。因为客户端一般使用系统自动分配的临时端口号来建立连接,而由于随机性,临时端口号一般和程序上一次使用的口号(还处于TIME_WAIT
状态的那个连接使用的端口号)不同,所以客户端程序一般可以立即重启。
但如果是服务器主动关闭连接后异常终止,则因为它总是使用同一个知名服务端口号,所以连接的TIME_WAIT
状态将导致它不能立即重启。不过,我们可以通过socket选项 SO_REUSEADDR 来强制进程立即使用处于TIME_WAIT
状态的连接占用的端口。
复位报文段 RST
产生复位报文段的三种情况:
1. 访问不存在的端口
当客户端程序访问一个不存在的端口时,目标主机将给它发送一个复位报文段。收到复位报文段的一端应该关闭连接或者重新连接,而不能回应这个复位报文段。
实际上,当客户端程序向服务器的某个端口发起连接,而该端口仍被处于TIME_WAIT
状态的连接所占用时,客户端程序也将收到复位报文段。
2. 异常终止连接
前面讨论的连接终止方式都是正常的终止方式:数据交换完成之后,一方给另一方发送结束报文段。TCP提供了异常终止一个连接的方法,即给对方发送一个复位报文段。一旦发送了复位报文段,发送端所有排队等待发送的数据都将被丢弃。
应用程序可以使用socket选项 SO_LINGER 来发送复位报文段,以异常终止一个连接。
3. 处理半打开连接
考虑下面的情况:服务器(或客户端)关闭或者异常终止了连接,而对方没有接收到结束报文段(比如发生了网络故障),此时,客户端(或服务器)还维持着原来的连接,而服务器(或客户端)即使重启,也已经没有该连接的任何信息了。我们将这种状态称为半打开状态,处于这种状态的连接称为半打开连接。如果客户端(或服务器)往处于半打开状态的连接写入数据,则对方将回应一个复位报文段。
延迟确认
延迟确认:服务器不马上确认上次收到的数据,而是在一段延迟时间后查看本端是否有数据需要发送,如果有,则和确认信息一起发出。因为服务器对客户请求处理得很快,所以它发送确认报文段的时候总是有数据一起发送。延迟确认可以减少发送TCP报文段的数量。而由于用户的输入速度明显慢于客户端程序的处理速度,所以客户端的确认报文段总是不携带任何应用程序数据。在TCP连接的建立和断开过程中,也可能发生延迟确认。
而广域网上的交互数据流可能经受很大的延迟,并且,携带交互数据的微小TCP报文段数量一般很多(一个按键输入就导致一个TCP报文段),这些因素都可能导致拥塞发生。解决该问题的一个简单有效的方法是使用Nagle算法。
Nagle算法要求一个TCP连接的通信双方在任意时刻都最多只能发送一个未被确认的TCP报文段,在该TCP报文段的确认到达之前不能发送其他TCP报文段。另一方面,发送方在等待确认的同时收集本端需要发送的微量数据,并在确认到来时以一个TCP报文段将它们全部发出。这样就极大地减少了网络上的微小TCP报文段的数量。该算法的另一个优点在于其自适应性:确认到达得越快,数据也就发送得越快。
带外数据
有些传输层协议具有带外(Out of Band,OOB)数据的概念,用于迅速通告对方本端发生的重要事件。因此,带外数据比普通数据(也称为带内数据)有更高的优先级,它应该总是立即被发送,而不论发送缓冲区中是否有排队等待发送的普通数据。带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中。实际应用中,带外数据的使用很少见,已知的仅有 telnet、ftp等远程非活跃程序。
UDP没有实现带外数据传输,TCP也没有真正的带外数据。不过TCP利用其头部中的紧急指针标志和紧急指针两个字段,给应用程序提供了一种紧急方式。TCP 的紧急方式利用传输普通数据的连接来传输紧急数据。这种紧急数据的含义和带外数据类似,因此后文也将 TCP 紧急数据称为带外数据。
实现可靠传输
滑动窗口
- **发送窗口:**发送方维持的一组连续的、允许发送帧的帧序号(大致分为四组,一组是已经发送并被确认的分组,一组是已经发送但还没有被确认的分组,一组是即将要发送的分组,一组是还没轮到要发送的分组,滑动窗口控制已经发送但没被确认和马上要发送的分组,当已经发送的数据被确认,窗口开始滑动,将没轮到的分组纳入滑动窗口。滑动窗口的最大尺寸由发送方设定,接收方收到数据包之后,会发送一个确认报文给发送方,发送方收到确认报文后会将滑动窗口移动)。主要对发送方进行流量控制。
- **接收窗口:**接收方维持的一组连续的、允许接收帧的帧序号。控制可接收哪些帧数据&不可接收哪些帧数据。当收到数据帧后,将窗口向前移动一个位置,并发回确认帧,若收到的数据帧落在接收窗口之外,则一律丢弃。
重传机制
超时重传
TCP 服务必须能够重传超时时间内未收到确认的 TCP 报文段。为此,TCP模块为每个TCP报文段都维护一个重传定时器,该定时器在 TCP 报文段第一次被发送时启动。如果超时时间内未收到接收方的应答,TCP模块将重传TCP 报文段并重置定时器。至于下次重传的超时时间如何选择,以及最多执行多少次重传,就是TCP的重传策略。
快速重传
如果发送方连续收到三个重复确认应答,则认为该数据包可能丢失,不必等待超时计时器到期,立即重传该数据包。
流量控制
接收方根据自己接收缓存的大小,通过其接收通过其接收通告窗口 (RWND),动态调整发送方发送窗口(Send Window, SWND)的大小,从而控制发送方的发送速率,避免出现发送方和接收方速度不匹配的问题。
实际上,在拥塞控制中,发送端引入了拥塞窗口(Congestion Window, CWND),最终的 SWND 取自 RWND 和 CWND 中的较小值。
拥塞控制
提高网络利用率,降低丢包率,防止过多的数据注入到网络中,使得通信网络链路过载。
拥塞控制的四个部分:慢启动(slow start)、拥塞避免 (congestion avoidance)、快速重传(fast retransmit)和快速恢复(fast recovery)
慢开始
在新连接建立或网络拥塞发生后的初始阶段,通过逐步增加拥塞窗口(cwnd)的大小来探测网络容量,避免网络突然过载。
初始阶段:拥塞窗口(cwnd)设置为一个较小的值(通常为1个最大报文段,MSS)。
指数增长:每次收到一个ACK(确认包),cwnd增加1个MSS。这样,cwnd呈指数增长。
在连接建立初期,发送方会先发送少量的数据,然后逐渐增加发送量。每收到一个确认应答(ACK),拥塞窗口(cwnd)的大小就会增加一个报文段(MSS)的大小。
当拥塞窗口的到达慢启动门限(slow start threshold size, ssthresh)时,就会进入拥塞避免阶段。
拥塞避免
- 拥塞避免算法在慢开始后期或网络已趋于稳定时,通过线性增长cwnd来防止网络拥塞。
- 线性增长:每经过一个 RTT(往返时间),cwnd增加一个MSS。
- 拥塞检测:如果检测到网络拥塞(通过超时或快速重传),ssthresh被设置为当前cwnd的一半,进入慢启动或快速恢复阶段。
快重传
接收方每收到一个失序的报文段后 就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方),而不要等到自己发送数据时才进行捎带确认。发送方只要一连收到3个重复确认就立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器到期。
- 当接收方发现丢失了一个报文段时,会立即发送三个重复的确认应答(而不是等待超时再发送),以通知发送方尽快重传丢失的报文段。
- 快重传可以减少不必要的等待时间,提高网络性能。
快恢复
快恢复机制在快速重传后立即调整拥塞窗口,使得网络迅速恢复到稳定状态。
- 当发送方收到三个重复的确认应答时,就认为发生了拥塞,但情况并不严重(只丢失了一个报文段)。此时,发送方会执行快恢复算法,将慢启动门限调整为当前拥塞窗口大小的一半(
ssthresh = ssthresh / 2
),并将拥塞窗口设置为慢启动门限加上3个报文段的大小。(CWND = ssthresh + 3 * MSS
) - 然后,发送方会开始发送新的报文段,并继续接收确认应答。如果再次收到重复的确认应答,就增加拥塞窗口的大小;如果收到新的确认应答,就表明所有丢失的报文段都已被接收方成功接收,此时可以将拥塞窗口设置为慢启动门限的值,并回到拥塞避免状态。
拥塞控制的状态转移
TCP在传输数据时,拥塞控制状态在不同阶段之间切换,具体包括:
- 慢启动(Slow Start):从连接建立或超时恢复后,TCP进入慢启动阶段。
- 拥塞避免(Congestion Avoidance):
cwnd
达到ssthresh
后进入此阶段。 - 快重传/快恢复(Fast Retransmit/Fast Recovery):检测到丢包(3个重复ACK)后进入此阶段,快速恢复窗口大小。
- 超时重传(Timeout Retransmit):如果ACK超时,TCP重传丢失的数据包,并重新进入慢启动阶段。