首页 TCP状态机和Linux网络编程
文章
取消

TCP状态机和Linux网络编程

TCP协议是传输层的可靠数据传输协议,是有连接、面向字节流的。TCP协议支持流量控制和拥塞控制,是一种广泛应用的协议。

与停等协议、比特交换协议等可靠传输协议类似,TCP协议也可以用状态机来表示。在Linux中,用了11种状态表示TCP协议的执行,便于人们了解当前连接处于什么阶段。当然,实际中TCP的行为比状态机中的更复杂,与实现可靠传输、流量控制和拥塞控制相关的状态被省略了。

TCP状态机

下图展示了TCP状态机中的11种状态。注意连接中的一方只能走完其中部分的状态,客户端和服务端的状态变化是不同的,它们经历的状态在图中分别用实线和虚线表示。

-16354356966601 TCP状态机1

若我们想实现一个基于TCP协议的服务器,并对外提供服务,就不得不与TCP状态机打交道。只有了解这些状态的含义,才能提升网络编程和调试的效率。

在Linux中,与TCP协议打交道的函数主要有listenacceptconnectclose。为了与TCP打交道,我们还需要借助套接字这一内核提供的接口,与之相关的函数有socketbind

可以用打电话的过程来类比借助TCP协议进行网络通信的过程。首先,我们要购买一部手机,这样才有打电话的前提条件,即与电信系统的接口,这就是socket()函数负责的业务,向内核申请一个套接字。有了手机还不够,我们需要手机号,用来代表我们的身份。申请一个手机号并将SIM卡插入手机,这就是bind()函数所做的,将协议地址与套接字绑定。在TCP协议中,协议地址即IP+端口号,可以通过这个地址找到网络上确定的一个端点,就像可以通过手机号找到我们购买的手机。做好上面的准备工作后,我们将手机开机,打开手机铃声,这样有人打电话就能被我们听到,这就是listen()函数所做的。输入手机号,拨通电话,connect()至对方手机,对方听到后accept(),此时对方屏幕上也显示了我们的手机号码。在使用read()write()通话结束后,close()

网络编程模型

基于上面介绍的函数,使用TCP协议通信的客户端-服务端网络编程模型可由下图表达:

-16354356966613 客户端-服务端 网络编程模型1

具体地,各个函数在调用后会产生如下的行为:

  • socket:传入使用的协议和套接字类型,让系统创建一个(主动)套接字,以进程文件描述符形式返回;
  • bind:将socket创建的套接字与特定的协议地址绑定,使这个套接字在系统中有了身份;
  • listen:将socket创建的套接字转为被动套接字,通知内核在网络上收到指向这个socket对应的协议地址的包时,转发到该套接字上(listenfd)。调用该函数后,TCP状态机从CLOSED转为LISTEN
  • accept:告知内核从连接就绪队列中获取一个客户端连接,此时会针对该连接创建一个新的套接字(**connfd**用于服务器后续与客户端之间的交互。listenfdconnfd是不一样的,若用4元组(源地址:源端口,目的地址:目的端口)表示TCP连接,则listenfd通常为(*:, *:*),而`connfd`为(:, :)。在创建时,若`listenfd`中的源地址为通配符地址,则server_addr取客户端报文中的目的地址。server_addr可能有多个,例如服务器有多个网卡。
  • close:减少套接字对应文件描述符的引用计数,当计数为0时,标记套接字为已关闭,不得再向其读写。当然,如果底层的数据缓冲区中仍有数据未发送,还会继续完成发送。(是的,这意味着当我们对套接字write时,数据不是立马就发送出去了。同时,还存在数据重传现象。)服务端通常是被动关闭,调用该函数时,从CLOSE_WAIT进入LAST_ACK。客户端则用该函数主动关闭连接,从ESTABLISHED进入FIN_WAIT_1
  • connect:由客户端发起,发起三次握手建立TCP连接。首先,发送SYN主动开启连接,从CLOSED进入SYN_SENT状态。服务器收到后从LISTEN进入SYN_RCVD状态,并返回ACKSYN,此为第二次握手。客户端收到后,发送ACK,此时双方进入ESTABLISHED状态。

三次握手,可以简化为两次吗?

要理解TCP为什么需要三次握手,而不是两次,要考虑网络中可靠传输协议的设计。在不可靠的网络中,可靠传输协议能够确保报文完整、按序、不重复地交付给应用层,而其面临的环境则是报文可能出错、丢失、(由于网络延时导致的)乱序和重复。任何报文都可能经历上述曲折,包括发起连接的SYN报文。如果TCP只要求两次握手,那么服务端每收到一个SYN报文就要开启一个连接,无法知晓该连接请求是“新鲜”的,还是经历了网络拥塞才迟迟到达的。如果是前者,那么服务端在返回ACK,交换各自的序列号后,客户端发现序列号正确,开始正常通信。如果是后者,客户端会发现是服务端回复的是过期的连接请求,期望服务器回复自己刚才重新发送的请求,则连接建立失败。

为避免历史错误连接的初始化,浪费服务器资源,TCP使用三次握手。2在最后一次握手时,客户端向服务器确认该连接请求是否“新鲜”,若“新鲜”,则建立连接,双方开始正常通信。否则,客户端发送RST终止连接。

-16354356966615 TCP的握手过程1

客户端使用connect函数发起三次握手,在服务端,内核负责处理连接的建立。具体而言,内核中维护了两个连接队列:连接就绪队列和连接未就绪队列。就绪意味着三次握手全部完成,双方进入ESTABLISHED状态,此时服务端已经可以用accept获取连接并与客户端交换数据。而未就绪队列中的连接仅完成了两次握手,服务器已发送ACK确认,等待客户端的再次回复。收到回复后,该连接随即加入就绪队列。

-16354356966617 连接队列1

在三次握手的过程中,双方要完成TCP连接中的一些重要设置。

在第一次和第二次握手时,双方要商定:

  • TCP选项
    • 最大报文段(maximum segment size, MSS):由于链路层有最大传输单元(maximum transmission unit, MTU)限制,通常为1500字节,因此传输层也要对最大报文段限制,最大可取到1460字节(除去了TCP和IP头各20字节)。当链路上有MTU小于最大值时,最小的MTU链路会成为整个连接的瓶颈。
    • 窗口缩放和时间戳:在高速网络中,TCP的接收窗口可以很大,超过65535,此时用缩放系数表示窗口的实际大小,即实际大小=窗口大小*缩放系数。引入时间戳后,可以用时间戳判断报文新旧,而不需要依赖序列号,因此也不用担心序列号重叠的问题。
  • 初始序列号
    • 为了保证数据的有序交付,判断报文是否过期,TCP引入了序列号机制。为了安全性,连接双方会随机生成一个初始序列号。

在第二次握手时,客户端分配连接资源,如缓冲区。

在第三次握手时,服务端分配资源。

SYN Flood攻击与网络嗅探

在TCP三次握手中,如果客户端没有完成第三次握手,那么服务器会在一段时间后(如1分钟)释放未就绪连接的资源(如将其从未就绪队列中移除)。如果客户端发起大量的SYN包,服务器的未就绪队列会被耗尽,导致无法接收新的连接请求,从而无法提供服务。这是一种最简单的拒绝服务(Denial of Service, DoS)攻击。服务器可使用SYN cookies3策略防御。

SYN包还可以用于网络嗅探。向服务器不同端口发送SYN包,观察服务器反应。

  • 返回ACK,说明该端口运行了某种服务。如果是熟知端口,那么很大可能该端口就是熟知端口对应的应用程序;
  • 返回RST,说明该端口是开放的,只是没有运行对应的服务;
  • 什么都没有返回,说明SYN被服务器防火墙阻断了。

握手可以是4次吗?

想到TCP是全双工的,参考4次挥手,理应可以用4次握手来建立连接,一次建立一个方向的连接。当两个方向连接都建立时,TCP连接正式建立。因此,网络中需要两个SYN包和两个ACK包。实际上,服务器返回客户端的ACK包中同时也设置了SYN。可以这么理解,SYN是服务器要发送的“数据”,占1个序列号,ACK则是捎带确认的。因为服务器除了SYN外没有额外数据要发送,因此4次握手可以压缩到3次。类似地,我们可以看到TCP状态机中,有“三次挥手”的情况,这也是因为服务器在收到客户端的FIN后,自己也没有额外数据要发送了,于是同时发送了ACK和自己的FIN,完成三次挥手。

再聊聊捎带确认

TCP使用捎带确认,即向对方发送消息时,顺便确认对方上次发来的消息序列号,以节省网络传输次数。但这不意味着总是要有消息发送时,才确认对方上次发来的消息。过迟的确认会导致对方计时器超时发起数据重传,这是我们不希望看到的。

在RFC 5681中,推荐如下确认规则:

  • 数据按序到达时,延迟ACK。等待500ms后若没有收到其它按序到达的报文段,则发送ACK。若期间有新的报文段按序到达,则连同新的报文段一起返回ACK。
  • 数据乱序到达,序列号高于期望值,立即发送ACK,以更快出发对端的三次冗余ACK快速重传。
  • 新的数据填补了有序数据与乱序数据之间的全部或部分空缺时,立即发送ACK。

可以认为,捎带确认是在一切“正常”的情况下进行的,即没有触发以上需要立即回复ACK的条件,并且在服务端有数据要发送时,顺便确认上次客户端的消息。若有上述情况或其它立即情况(如需要返回RST的情况),则立即回复。

四次挥手中,发生了什么?

TCP是全双工的,因此连接要从两个方向分别关闭,一组握手对应于一个方向的关闭。为了确保完美关闭,TCP要保证4次握手中,4个报文段都被正确接收了。

理解TIME_WAIT

只有主动关闭的一方(通常是客户端)会进入TIME_WAIT。服务端虽然也会调用close,但通常是收到来自客户端的FIN包后才进入连接关闭的流程。在TIME_WAIT阶段,等待2MSL(maximum segment lifetime)后才彻底释放连接。实现TCP协议时,要指定MSL大小,在RFC 1122中为2分钟,大部分Linux中为30秒。设置MSL时,假设一个IP报文段在网络中经过最多255跳时,需要的时间不超过MSL的秒数。诚然,一个异常的报文段在MSL秒后,仍可能到达目的地,因此仍然需要处理这种情况。

主动关闭方进入TIME_WAIT状态的作用4

  • 保证被动关闭方顺利关闭,即确认对端已收到FIN对应的ACK

  • 防止迟来的数据段被后来创建的使用相同源地址、源端口、目的地址以及目的端口的 TCP 连接收到(这个4元组唯一确定一个TCP连接)。

如果主动关闭方(客户端)向对端的FIN包返回的ACK没被收到,被动关闭方(服务器)会认为FIN发送失败,进行重传。此时,如果客户端已经进入CLOSED状态,会对FIN包返回RST。在网络编程中,RST意味着出错,本来正常的关闭流程现在导致服务器进入了错误处理流程,这是我们不希望看到的。

另一方面,由于4元组(源地址:源端口,目的地址:目的端口)唯一确定了一个TCP连接,因此连接关闭后,很快可能又会有相同4元组的连接被创建。前一次连接中迟来的报文段会被新的连接收到,很可能产生错误接收。

以上两方面考虑中,2MSL都是必须的。首先,服务器需要1个MSL来发现客户端返回的ACK丢了,然后重传FIN包,需要1MSL到达客户端。客户端此时收到FIN包后会重置TIME_WAIT计时器,再次返回ACK。客户端等待2MSL后如果没有再次收到服务器的FIN包,则可确认服务器已经成功关闭了。此外,对于网络中还存在的冗余、延迟的请求包,需要1MSL让它们消失,还需要1MSL让请求的响应包消失。

处于TIME_WAIT的连接过多,到极限情况时,所有端口号都被占用。如果源地址、目的地址、目的端口确定时,所有源端口都在等待TIME_WAIT释放,那么此时就无法建立与目标服务器的连接了。

边界情况

状态图中给出了两个边界情况:

  1. 双方同时发起关闭,各自进入CLOSING状态。在收到对方的确认ACK之后,不再发送ACK。由于双方都主动发起关闭,因此TCP连接的两个方向都被关闭了,自然完成了4次挥手的过程。当然,此时进入的仍然是TIME_WAIT,需要再等待2MSL。
  2. 服务端收到FIN后,立刻也调用了close关闭连接,同时捎带确认返回ACK。客户端收到后进入TIME_WAIT状态,同时发送ACK确认。此时,4次挥手被压缩到了3次。可见,4次挥手的本质就是要从两个方向分别关闭连接,因为TCP是全双工的。

总结

本文介绍了TCP状态机,展示了里面11种状态。介绍了基于TCP的客户端-服务端编程模型,以及其中用到的重要函数,说明这些函数与状态机之间的关系:

  • socket:创建一个主动套接字;
  • bind:将socket创建的套接字与特定的协议地址绑定;
  • listen:服务器调用,将socket创建的套接字转为被动套接字。TCP状态机从CLOSED转为LISTEN
  • accept:服务器调用,告知内核从连接就绪队列中获取一个客户端连接;
  • close:客户端主动关闭连接,从ESTABLISHED进入FIN_WAIT_1。服务端通常是被动关闭,从CLOSE_WAIT进入LAST_ACK
  • connect:由客户端发起,发起三次握手。首先,发送SYN主动开启连接,从CLOSED进入SYN_SENT状态,在收到ACK后进入ESTABLISHED。服务器第一次握手后从LISTEN进入SYN_RCVD状态,并返回ACKSYN,收到客户端的ACK后,进入ESTABLISHED状态。

之后,我们讨论了三次握手与四次挥手,可以从TCP为全双工及使用捎带确认机制的角度理解为何是这两个数字。再考虑到网络环境总是“不靠谱”的,会出现数据包出错、丢失、乱序和重复,为了确保“所有”数据包完整、按序、不重复地到达,TCP引入了TIME_WAIT状态。这一设计背后的原因与TCP为什么不能只使用两次握手也是有重合的,因为“所有”包括建立连接和关闭连接时的SYNFIN包。总结一下,即:

  • 三次握手的作用:避免历史错误连接的初始化,浪费服务器资源;
  • TIME_WAIT 状态的作用:
    • 保证被动关闭方顺利关闭,即确认对端已收到FIN对应的ACK
    • 防止迟来的数据段被后来创建的使用相同源地址、源端口、目的地址以及目的端口的 TCP 连接收到(这个4元组唯一确定一个TCP连接)。

References

本文由作者按照 CC BY 4.0 进行授权

-

「网络编程101」来封装一个简单的TCP服务吧!