TCP协议中三次握手和四次挥手


一、三次握手流程

推荐阅读这篇文章

  1. 客户端调用connect系统调用,发出第一次握手
    • 找到套接字:创建内核对象的时候,fd会跟file对象做通过fd_install关联起来,通过进程的fd_table就可以找到对应的file,而file的private指针就指向了socket对象,所以根据fd即可找到套接字
    • 判断当前套接字的状态:只有SS_UNCONNECTED状态(刚创建的套接字就是该状态)才会继续,其他状态都会报错
      • 注意此处是socket的状态,而不是sock的状态
      • 会将socket状态更改为SS_CONNECTING
    • 更改sock状态为TCP_SYN_SENT
    • 绑定端口:如果当前套接没有bind端口(端口为0则表示没有绑定),则从ip_local_port_range的某一个随机位置开始循环遍历找到合适的端口,如果查询不到则抛出Cannot assign requested address的错误
    • 申请skb加入发送队列并设置syn数据包:将SYN标志位置为1,随机生成一个序列号,并设置MSS等字段,随后将数据包发送出去
      • 这里直接调用的是tcp_transmit_skb,而正常发送逻辑会从tcp_sendmsg开始,其中会检查当前套接字的状态,如果不是已建立或者CLOSE_WAIT,会等待连接建立
    • 启动重传定时器:等到一定时间后收不到服务端的反馈的时候来开启重传。首次超时时间是在TCP_TIMEOUT_INIT宏中定义的,该值在Linux3.10版本是1秒, 在一些老版本中是3秒。每次超时时间为2的指数递增(1,2,4,8,16,32)
  2. 服务端收到SYN包之后,发出第二次握手
    • 找到套接字:skb通过软中断不断上传到tcp协议栈,根据数据报首部的IP地址和端口号查找对应的socket对象
    • 判断当前套接字的状态:这里会进入TCP_LISTEN的处理逻辑
    • 判断连接是否已经建立:检查是否有与这个SYN包的四元组相同的连接已经存在
      • 如果已经存在ESTABLISHED状态的连接,则丢弃该握手包
      • 如果已经存在SYN_RCVD状态的连接,则可能是一个重传的SYN包,这个时候会继续一下逻辑
    • 查找半连接队列:到套接字的半连接队列中查找是否存在对应的半连接对象,因为当前是第一次握手,所以显然队列中还不存在半连接对象
    • 创建半连接对象加入队列:会先检查半连接队列和全连接队列是否已满,如果数据包没有被丢弃则创建request_sock对象,将状态设置为TCP_SYN_RCVD
      • 如果半连接队列满了且还没有开启syn_cookies那么将直接把握手包丢弃
      • 如果全连接队列已满且存在young ack则同样把握手包丢弃
    • 构造synack包并发送:将ACK和SYN标志位都置为1,随机生成一个序列号,并将确认号设置为SYN包的序列号+1,同时设置MSS等字段,最后将数据包发送出去
    • 半连接对象入队:将半连接对象request_sock加入半连接队列
    • 开启重传定时器
  3. 客户端收到SYNACK包之后,发出第三次握手
    • 找到套接字:skb通过软中断不断上传到tcp协议栈,根据数据报首部的IP地址和端口号查找对应的socket对象
    • 判断当前套接字的状态:这里会进入TCP_SYN_SENT的处理逻辑
    • 移除重传队列中的SYN包,停止计时器
    • 更改sock状态为ESTABLISHED
    • 初始化TCP连接的拥塞控制算法、接收缓存和发送缓存空间等信息
    • 开启keep alive保活计时器
    • 唤醒等待队列的进程
    • 构造ACK包进行发送:判断是否满足TCP的延迟确认(Delayed ACK)机制,如果满足则和数据包一起发送
  4. 服务端收到ACK包之后,完成连接建立
    • 找到套接字:skb通过软中断不断上传到tcp协议栈,根据数据报首部的IP地址和端口号查找对应的socket对象
    • 判断当前套接字的状态:这里会进入TCP_LISTEN的处理逻辑(注意不是TCP_SYN_RCVD)
    • 查找半连接队列:这次是响应第三次握手,在上一次已经创建了半连接对象放置在队列中,所以这次可以从队列中拿到半连接对象
    • 创建sock对象:判断socket的全连接队列是否满了,没满则根据半连接对象创建子sock
      • 会将sock状态设置为ESTABLISHED,并且关联到这个request_sock
      • 随后将request_sock其从半连接队列移除,加入到全连接队列中
    • 唤醒等待队列的进程:如果有进程调用accept等待连接的话,则会被唤醒
      • 唤醒之后进程从全连接队列中拿到request_sock
      • 之后就可以根据request_sock中保存的tcp_sock来创建socket对象

二、为什么握手是三次

如果不进行最后一次握手,即服务端返回synack报文之后就完成建立的话,那么在数据包延迟到达的情况下有可能出现问题。

  • 第一次握手包延迟:假如说客户端发起的SYN数据包因为网络延迟没有到达服务端,那么这时候它就收不到服务端的SYNACK报文,那么此时它就会超时重传SYN数据包。如果这次服务端收到了并回复了SYNACK报文,那么连接就算建立成功了。而在连接建立成功并且通讯完成又释放了之后,第一次因为延迟而没有到达服务端的SYN数据包这时候到达了,这时候服务端会以为是一个新的连接到达,于是再次响应一个SYNACK报文,因为只有两次握手,所以就建立了一条本不应该存在的连接。而如果此时使用的是三次握手,那么客户端收到这条SYNACK报文后则会将其丢弃,不会完成连接的建立。
  • 第二次握手包延迟:如果服务端收到了客户端的SYN报文,而回传SYNACK包的时候超时了,那么如果此时是两次握手,服务端直接认为连接建立成功,而客户端会以为是自己的SYN报文没有到达服务端而重传SYN数据包,那么服务端会因为已经建立连接(自己认为已经建立过了)所以丢弃重传的SYN包,所以客户端这边永远都连不上。而如果此时使用的是三次握手,那么在SYNACK包超时之后,除了客户端重发SYN包,服务端也会重发SYNACK包。那么客户端收到重发的SYNACK包之后会发出ACK包,而服务端收到重发的SYN包后会再次发出SYNACK包。这时这个新的SYNACK包到达之后会因为序列号对不上而被客户端丢弃,而服务端收到ACK包之后就完成连接的建立。建立完成之后即使延迟的SYNACK包到达了客户端,也会同样被丢弃。
  • 第三次握手包延迟(当然只可能在三次握手时出现):客户端发出ACK报文之后是不会期待响应的,所以此时它会直接认为连接建立。而服务端会因为收不到ACK包而重传SYNACK包,那么客户端会再次发送ACK包,如果服务端收到则完成建立。如果重试多次后一直丢失,那么服务端会认为连接建立失败而关闭连接。后续如果客户端正常的发出数据包给服务端,则会收到RST包,从而意识到连接已经被关闭。也就是说没有必要有第四次握手,如果ACK包一直丢失不能建立连接,后续发送数据的时候就可以感知到。

三、关闭连接的情况

关闭连接有以下几种可能:

  1. 超时:如果在设定的超时时间内没有收到期望的ACK包或其他响应,TCP连接会被关闭。这是为了防止网络中的“僵尸连接”消耗系统资源。
  2. 错误或异常:如果发生了某些错误或异常,例如网络错误、对方突然断线或程序崩溃等,TCP连接也会被关闭。
  3. 主动关闭:如果应用程序调用了关闭连接的函数(例如close和shutdown),TCP连接也会被关闭。
  4. RST标志:如果收到一个带有RST(Reset)标志的TCP包,TCP连接也会被立即关闭。RST包通常在发生错误或异常时被发送,例如,收到了一个不应该收到的包,或者试图打开一个不存在的连接等。

如果是发生了如网络错误、断线或程序崩溃等错误或异常,那么自己这边的系统可能关闭所有的网络接口,释放所有的网络相关的内存等(取决于操作系统和协议栈的实现),而对端发送的保活数据包将接收不到ACK,重试几次后,就会进行连接的关闭,这个时候是不会进行四次挥手的。

而如果是发生以下情况,就会发送一个带有RST标志的TCP包。收到带有RST标志的包的一方会立即关闭连接,而不需要执行常规的四次挥手过程。这种情况下,连接的关闭是非正常的,因为它并没有经过正常的关闭过程就被终止了。

  1. 收到了一个错误的序列号的数据包:在TCP连接中,每个数据包都有一个序列号,用来保证数据包的有序接收。如果收到了一个序列号不正确的数据包(即这个数据包的序列号不在期望的序列号范围内),TCP会发送一个RST数据包来重置连接
  2. 应用程序强制关闭:应用程序在正常关闭一个TCP连接时,会通过操作系统发送一个FIN(Finish)标志的数据包,这将触发TCP的正常关闭流程,也就是所谓的”四次挥手”。然而,有一种特殊的情况,那就是”强制关闭”或”紧急关闭”。在某些情况下,例如,应用程序崩溃,或者用户想要立即关闭连接,而不等待四次挥手过程完成,操作系统会发送一个RST(Reset)标志的数据包来立即关闭连接。这种情况下,操作系统并不会等待对方的确认,连接会立即关闭。可以使用SO_LINGER选项来设置一个0延迟的linger时间以实现强制关闭
  3. 网络层错误或异常:在某些网络层的错误或异常情况下,例如,网络接口出错或者IP路由失败,TCP可能会发送一个RST数据包来关闭连接。
  4. TCP层错误或异常:在某些TCP层的错误或异常情况下,例如,内存不足,无法创建新的数据包,或者处理到一半的数据包被意外丢失,TCP可能会发送一个RST数据包来关闭连接。

也就是以上的情况其实都不会进行四次挥手,只有当正常进行连接的关闭才会进行四次挥手的逻辑。当应用程序A决定关闭一个TCP连接时,它会调用 close() 或 shutdown() 函数,这些函数在操作系统内部会发送一个FIN(Finish)标志的TCP数据包给对端B,这就开始了所谓的四次挥手过程。

四、四次挥手流程

  1. 主动方调用close或shutdown,发起第一次挥手

    • 取消文件描述符和file对象指针的关联:即后续无法再使用这个文件描述符,shutdown不会关闭fd,所以仍需要调用close来关闭文件描述符
      • lose在fd被多个进程持有时不会立马关闭连接,调用close只会让引用计数-1,需要等到socket的引用计数为0才会发送FIN报文
      • 而shutdown会直接关闭连接
    • 判断当前套接字状态:如果是LISTEN则直接设置为close,然后释放对象,结束流程
    • 释放接收队列:如果不是LISTEN状态则循环遍历接收队列,释放队列中的skb
      • 如果接收队列不为空,即释放了skb,则会发送一个RST来中断连接,然后更改套接字状态并释放相关资源,结束流程
    • 判断是否设置了SO_LINGER:如果设置了该选项并且linger时间设置为0,那么也发送RST直接中断连接
      • 默认是没有设置该选项的,close方法不会阻塞的,在后台进行处理
      • 如果设置为0,则立即关闭连接,发送缓冲区有未发送的数据则直接丢弃,直接进入CLOSED
    • 发送FIN数据包:只有没有设置SO_LINGER或者设置了非0的linger才会来到这里
      • 更新自身状态为FIN_WAIT_1(状态机中ESTABLISH的下一位)
      • 遍历发送队列,如果其中有数据包未发送就在最后一个数据包设置FIN标志位,然后将所有数据包发送出去。
      • 如果设置了SO_LINGER,则进程进入阻塞,等待linger时间,如果超时仍然没有发完则会发送RST报文。
      • 默认没有设置linger(不会阻塞)则会检测当前socket状态。如果是FIN_WAIT1(一般来说没有阻塞直接返回就是这个状态),就会查看孤儿socket数量是不是太多了,如果是则更改为CLOSE状态并发送RST直接关闭,不是则tcp_close函数到此基本结束
  2. 服务端收到FIN包之后,发起第二次挥手

    • 找到套接字,检查到套接字状态:因为当前是ESTABLISHED状态,所以进入tcp_rcv_established,并在最终检测到数据报的FIN标志位为1而进入tcp_fin函数进行处理
    • 更改套接字状态:对于ESTABLISHED状态的套接字,会将其更改为CLOSE_WAIT状态
    • 内存清理:清空乱序队列中的数据包,并且根据当前的内存压力和套接字的内存使用情况来回收一部分内存
    • 唤醒阻塞的进程:通知在recv上等待的进程有数据可读,此时读取的返回结果会是0
      • 此时服务端已经知道对端已经关闭连接,然后就可以编写逻辑来决定何时调用close方法
    • 发送ACK包给客户端
  3. 客户端收到ACK包

    • 找到套接字,检查到套接字状态:因为当前的状态是TCP_FIN_WAIT1,所以会进去一个状态处理函数tcp_rcv_state_process(如果不是LISTEN或是ESTABLISHED就会进入这个函数)
    • 更改套接字状态:将自身的状态更改为TCP_FIN_WAIT2
    • 设置定时器:TMO+2MSL或者基于RTO计算超时
      • 超时后会直接变迁到closed状态,然后将套接字的发送端设置为关闭
    • 唤醒阻塞在close上的进程:针对于设置了SO_LINGER的情况,被唤醒后继续执行close后续逻辑
      • 检测linger2是否大于等于0(TCP层面的,用于设定孤儿套接字在FIN_WAIT2状态的生存时间,如果没有配置则默认为tcp_fin_timeout,如果大于则等待一段时间来接收对端的FIN,如果小于0则立即关闭连接,并发送RST报文
  4. 服务端继续处理,发送数据包给客户端

    • 服务端在知道客户端关闭连接后还可以继续发送数据包

    • 如果客户端关闭了读通道(close会都关闭),那么客户端收到数据包后会发送RST数据包之后服务端直接进行关闭

      在这里插入图片描述

    • 如果客户端只是关闭了写通道(shutdown可以只关闭写),那么数据包会照常接收并返回ACK报文

      在这里插入图片描述

  5. 当服务端处理完毕之后,调用close方法,发起第三次挥手

    • 释放接收队列:如果有skb释放或者socket设置了SO_LINGER选项且linger时间为0,那么还是发送RST
    • 更新套接字状态 :如果不是上面两种情况则继续更新到状态机的下一位,因为当前是CLOSE_WAIT,所以更新成LAST_ACK
      • CLOSE_WAIT会在保活定时器超时后强行关闭连接,用于服务端一直没有主动关闭连接而客户端已经因为超时而关闭的情况。
    • 发送FIN包给客户端
  6. 客户端收到FIN包之后,发起第四次挥手

    • 发送ACK数据包:同样进入tcp_fin函数,发送ack包给服务端然
    • 更改套接字状态:为当前状态是TCP_FIN_WAIT2,更改至TIME_WAIT
    • 内存释放:time_wait状态时,原socket会被destroy,然后新创建一个inet_timewait_sock,在等待2MSL之后删除。
  7. 服务端收到客户端的ACK包之后,完成四次挥手:将LAST_ACK更改为CLOSED,并且释放对象

如果两边同时发送FIN,那么在FIN_WAIT_1时收到对方的FIN,会进入CLOSING,之后收到ACK变成TIME_WAIT

五、为什么挥手是四次

不同于握手,SYN和ACK可以同时发送。FIN表示的是自己没有数据要发了,而在客户端结束发送数据的时候,不一定服务端也结束了,所以没办法将FIN包和ACK包结合在一起发送。

对于可靠连接而言,ACK包是不可以省略的,每一个方向上的数据发送都应该得到对端的确认。并且假如说节省第二次挥手的ACK包,那么因为下一个FIN的时间是不确定的,有可能很久,那么实现的时候得让FIN_WAIT_1等待一个很久的时间。如果它是因为丢包了,那么重试也会需要一个很长的时间,这会导致close的时间非常的久。如果节省最后一个ACK包,也就是说被动方发出FIN之后就关闭,主动方收到FIN之后也直接关闭。那么有可能FIN包丢失了,所以导致被动方关闭了而主动方还在等待。

而最后需要进入TIME_WAIT状态等待2MSL的原因主要有两个:

  1. 保证老的重复报文在网络中消逝:如果说没有TIME_WAIT两个2MSL,而客户端和服务端又基于原本的端口建立了新的连接,那么旧连接中可能有数据包延迟,没达到最大生存时间,所以还没被丢弃,这个时候到达了新的连接,并且正好在接收窗口中,那么此时会被误以为是正常的数据包,从而导致新的连接数据错乱。序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。所以通过设置2MSL,保证新连接建立的时候,旧连接在网络中残留的数据包都已经死亡了
  2. 如果主动关闭方的ACK丢失,那么被动方会重发FIN包,以允许主动方重发ACK,那么此时如果没有TIMEWAIT,则主动方已经关闭了,无法重发ACK报文,TCP协议栈会返回 RST 报文,RST其实是出现异常的时候才发送的数据包,这对于可靠的TCP协议而言不是一个比较优雅的关闭方式。

如果出现过多的TIME_WAIT,想要缩短TIME_WAIT的时间,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接(这两个参数都是默认关闭的),分别是net.ipv4.tcp_tw_reusenet.ipv4.tcp_tw_recycle

六、Linux网络性能优化的建议

建议1:尽量减少不必要的网络 IO

我要给出的第一个建议就是不必要用网络 IO 的尽量不用。

是的,网络在现代的互联网世界里承载了很重要的角色。用户通过网络请求线上服务、服务器通过网络读取数据库中数据,通过网络构建能力无比强大分布式系统。网络很好,能降低模块的开发难度,也能用它搭建出更强大的系统。但是这不是你滥用它的理由!

原因是即使是本机网络 IO 开销仍然是很大的。先说发送一个网络包,首先得从用户态切换到内核态,花费一次系统调用的开销。进入到内核以后,又得经过冗长的协议栈,这会花费不少的 CPU 周期,最后进入环回设备的“驱动程序”。接收端呢,软中断花费不少的 CPU 周期又得经过接收协议栈的处理,最后唤醒或者通知用户进程来处理。当服务端处理完以后,还得把结果再发过来。又得来这么一遍,最后你的进程才能收到结果。你说麻烦不麻烦。另外还有个问题就是多个进程协作来完成一项工作就必然会引入更多的进程上下文切换开销,这些开销从开发视角来看,做的其实都是无用功。

上面我们还分析的只是本机网络 IO,如果是跨机器的还得会有双方网卡的 DMA 拷贝过程,以及两端之间的网络 RTT 耗时延迟。所以,网络虽好,但也不能随意滥用!

建议2:尽量合并网络请求

在可能的情况下,尽可能地把多次的网络请求合并到一次,这样既节约了双端的 CPU 开销,也能降低多次 RTT 导致的耗时。

我们举个实践中的例子可能更好理解。假如有一个 redis,里面存了每一个 App 的信息(应用名、包名、版本、截图等等)。你现在需要根据用户安装应用列表来查询数据库中有哪些应用比用户的版本更新,如果有则提醒用户更新。

那么最好不要写出如下的代码:

<?php 
for(安装列表 as 包名){
  redis->get(包名)
  ...
}

上面这段代码功能上实现上没问题,问题在于性能。据我们统计现代用户平均安装 App 的数量在 60 个左右。那这段代码在运行的时候,每当用户来请求一次,你的服务器就需要和 redis 进行 60 次网络请求。总耗时最少是 60 个 RTT 起。更好的方法是应该使用 redis 中提供的批量获取命令,如 hmget、pipeline等,经过一次网络 IO 就获取到所有想要的数据,如图。

建议3:调用者与被调用机器尽可能部署的近一些

在前面的章节中我们看到在握手一切正常的情况下, TCP 握手的时间基本取决于两台机器之间的 RTT 耗时。虽然我们没办法彻底去掉这个耗时,但是我们却有办法把 RTT 降低,那就是把客户端和服务器放的足够的近一些。尽量把每个机房内部的数据请求都在本地机房解决,减少跨地网络传输。

举例,假如你的服务是部署在北京机房的,你调用的 mysql、redis最好都位于北京机房内部。尽量不要跨过千里万里跑到广东机房去请求数据,即使你有专线,耗时也会大大增加!在机房内部的服务器之间的 RTT 延迟大概只有零点几毫秒,同地区的不同机房之间大约是 1 ms 多一些。但如果从北京跨到广东的话,延迟将是 30 - 40 ms 左右,几十倍的上涨!

建议4:内网调用不要用外网域名

假如说你所在负责的服务需要调用兄弟部门的一个搜索接口,假设接口是:”http://www.sogou.com/wq?key=开发内功修炼"。

那既然是兄弟部门,那很可能这个接口和你的服务是部署在一个机房的。即使没有部署在一个机房,一般也是有专线可达的。所以不要直接请求 www.sogou.com, 而是应该使用该服务在公司对应的内网域名。在我们公司内部,每一个外网服务都会配置一个对应的内网域名,我相信你们公司也有。

为什么要这么做,原因有以下几点

1)外网接口慢。本来内网可能过个交换机就能达到兄弟部门的机器,非得上外网兜一圈再回来,时间上肯定会慢。

2)带宽成本高。在互联网服务里,除了机器以外,另外一块很大的成本就是 IDC 机房的出入口带宽成本。两台机器在内网不管如何通信都不涉及到带宽的计算。但是一旦你去外网兜了一圈回来,行了,一进一出全部要缴带宽费,你说亏不亏!!

3)NAT 单点瓶颈。一般的服务器都没有外网 IP,所以要想请求外网的资源,必须要经过 NAT 服务器。但是一个公司的机房里几千台服务器中,承担 NAT 角色的可能就那么几台。它很容易成为瓶颈。我们的业务就遇到过好几次 NAT 故障导致外网请求失败的情形。NAT 机器挂了,你的服务可能也就挂了,故障率大大增加。

建议5:调整网卡 RingBuffer 大小

在 Linux 的整个网络栈中,RingBuffer 起到一个任务的收发中转站的角色。对于接收过程来讲,网卡负责往 RingBuffer 中写入收到的数据帧,ksoftirqd 内核线程负责从中取走处理。只要 ksoftirqd 线程工作的足够快,RingBuffer 这个中转站就不会出现问题。

但是我们设想一下,假如某一时刻,瞬间来了特别多的包,而 ksoftirqd 处理不过来了,会发生什么?这时 RingBuffer 可能瞬间就被填满了,后面再来的包网卡直接就会丢弃,不做任何处理!

通过 ethtool 就可以加大 RingBuffer 这个“中转仓库”的大小。。

# ethtool -G eth1 rx 4096 tx 4096

这样网卡会被分配更大一点的”中转站“,可以解决偶发的瞬时的丢包。不过这种方法有个小副作用,那就是排队的包过多会增加处理网络包的延时。所以应该让内核处理网络包的速度更快一些更好,而不是让网络包傻傻地在 RingBuffer 中排队。我们后面会再介绍到 RSS ,它可以让更多的核来参与网络包接收。

建议6:减少内存拷贝

假如你要发送一个文件给另外一台机器上,那么比较基础的做法是先调用 read 把文件读出来,再调用 send 把数据把数据发出去。这样数据需要频繁地在内核态内存和用户态内存之间拷贝,如图 9.6。

目前减少内存拷贝主要有两种方法,分别是使用 mmap 和 sendfile 两个系统调用。使用 mmap 系统调用的话,映射进来的这段地址空间的内存在用户态和内核态都是可以使用的。如果你发送数据是发的是 mmap 映射进来的数据,则内核直接就可以从地址空间中读取,这样就节约了一次从内核态到用户态的拷贝过程。

不过在 mmap 发送文件的方式里,系统调用的开销并没有减少,还是发生两次内核态和用户态的上下文切换。如果你只是想把一个文件发送出去,而不关心它的内容,则可以调用另外一个做的更极致的系统调用 - sendfile。在这个系统调用里,彻底把读文件和发送文件给合并起来了,系统调用的开销又省了一次。再配合绝大多数网卡都支持的”分散-收集”(Scatter-gather)DMA 功能。可以直接从 PageCache 缓存区中 DMA 拷贝到网卡中。这样绝大部分的 CPU 拷贝操作就都省去了。

建议7:使用 eBPF 绕开协议栈的本机 IO

如果你的业务中涉及到大量的本机网络 IO 可以考虑这个优化方案。本机网络 IO 和跨机 IO 比较起来,确实是节约了驱动上的一些开销。发送数据不需要进 RingBuffer 的驱动队列,直接把 skb 传给接收协议栈(经过软中断)。但是在内核其它组件上,可是一点都没少,系统调用、协议栈(传输层、网络层等)、设备子系统整个走 了一个遍。连“驱动”程序都走了(虽然对于回环设备来说这个驱动只是一个纯软件的虚拟出来的东东)。

如果想用本机网络 IO,但是又不想频繁地在协议栈中绕来绕去。那么你可以试试 eBPF。使用 eBPF 的 sockmap 和 sk redirect 可以绕过 TCP/IP 协议栈,而被直接发送给接收端的 socket,业界已经有公司在这么做了。

建议8:尽量少用 recvfrom 等进程阻塞的方式

在使用了 recvfrom 阻塞方式来接收 socket 上数据的时候。每次一个进程专⻔为了等一个 socket 上的数据就得被从 CPU 上拿下来。然后再换上另一个 进程。等到数据 ready 了,睡眠的进程又会被唤醒。总共两次进程上下文切换开销。如果我们服务器上需要有大量的用户请求需要处理,那就需要有很多的进程存在,而且不停地切换来切换去。这样的缺点有如下这么几个:

  • 因为每个进程只能同时等待一条连接,所以需要大量的进程。
  • 进程之间互相切换的时候需要消耗很多 CPU 周期,一次切换大约是 3 - 5 us 左右。
  • 频繁的切换导致 L1、L2、L3 等高速缓存的效果大打折扣

大家可能以为这种网络 IO 模型很少见了。但其实在很多传统的客户端 SDK 中,比如 mysql、redis 和 kafka 仍然是沿用了这种方式。

建议9:使用成熟的网络库

使用 epoll 可以高效地管理海量的 socket。在服务器端。我们有各种成熟的网络库进行使用。这些网络库都对 epoll 使用了不同程度的封装。

首先第一个要给大家参考的是 Redis。老版本的 Redis 里单进程高效地使用 epoll 就能支持每秒数万 QPS 的高性能。如果你的服务是单进程的,可以参考 Redis 在网络 IO 这块的源码。

如果是多线程的,线程之间的分工有很多种模式。那么哪个线程负责等待读 IO 事件,哪个线程负责处理用户请求,哪个线程又负责给用户写返回。根据分工的不同,又衍生出单 Reactor、多 Reactor、以及 Proactor 等多种模式。大家也不必头疼,只要理解了这些原理之后选择一个性能不错的网络库就可以了。比如 PHP 中的 Swoole、Golang 的 net 包、Java 中的 netty 、C++ 中的 Sogou Workflow 都封装的非常的不错。

建议10:使用 Kernel-ByPass 新技术

如果你的服务对网络要求确实特别特特别的高,而且各种优化措施也都用过了,那么现在还有终极优化大招 – Kernel-ByPass 技术。

内核在接收网络包的时候要经过很⻓的收发路径。在这期间牵涉到很多内核组件之间的协同、协议栈的处理、以及内核态和用户态的拷贝和切换。Kernel-ByPass 这类的技术方案就是绕开内核协议栈,自己在用户态来实现网络包的收发。这样不但避开了繁杂的内核协议栈处理,也减少了频繁了内核态用户态之间的拷贝和切换,性能将发挥到极致!

目前我所知道的方案有 SOLARFLARE 的软硬件方案、DPDK 等等。如果大家感兴趣,可以多去了解一下!

建议11:配置充足的端口范围

客户端在调用 connect 系统调用发起连接的时候,需要先选择一个可用的端口。内核在选用端口的时候,是采用从可用端口范围中某一个随机位置开始遍历的方式。如果端口不充足的话,内核可能需要循环撞很多次才能选上一个可用的。这也会导致花费更多的 CPU 周期在内部的哈希表查找以及可能的自旋锁等待上。因此不要等到端口用尽报错了才开始加大端口范围,而且应该一开始的时候就保持一个比较充足的值。

# vi /etc/sysctl.conf
net.ipv4.ip_local_port_range = 5000 65000
# sysctl -p  //使配置生效

如果端口加大了仍然不够用,那么可以考虑开启端口 reuse 和 recycle。这样端口在连接断开的时候就不需要等待 2MSL 的时间了,可以快速回收。开启这个参数之前需要保证 tcp_timestamps 是开启的。

# vi /etc/sysctl.conf
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tw_recycle = 1
# sysctl -p

建议12:小心连接队列溢出

服务器端使用了两个连接队列来响应来自客户端的握手请求。这两个队列的长度是在服务器 listen 的时候就确定好了的。如果发生溢出,很可能会丢包。所以如果你的业务使用的是短连接且流量比较大,那么一定得学会观察这两个队列是否存在溢出的情况。因为一旦出现因为连接队列导致的握手问题,那么 TCP 连接耗时都是秒级以上了。

对于半连接队列, 有个简单的办法。那就是只要保证 tcp_syncookies 这个内核参数是 1 就能保证不会有因为半连接队列满而发生的丢包。

对于全连接队列来说,可以通过 netstat -s 来观察。netstat -s 可查看到当前系统全连接队列满导致的丢包统计。但该数字记录的是总丢包数,所以你需要再借助 watch 命令动态监控。

# watch 'netstat -s | grep overflowed' 
160 times the listen queue of a socket overflowed //全连接队列满导致的丢包

如果输出的数字在你监控的过程中变了,那说明当前服务器有因为全连接队列满而产生的丢包。你就需要加大你的全连接队列的⻓度了。全连接队列是应用程序调用 listen时传入的 backlog 以及内核参数 net.core.somaxconn 二者之中较小的那个。如果需要加大,可能两个参数都需要改。

如果你手头并没有服务器的权限,只是发现自己的客户端机连接某个 server 出现耗时长,想定位一下是否是因为握手队列的问题。那也有间接的办法,可以 tcpdump 抓包查看是否有 SYN 的 TCP Retransmission。如果有偶发的 TCP Retransmission, 那就说明对应的服务端连接队列可能有问题了。

建议13:减少握手重试

在 6.5 节我们看到如果握手发生异常,客户端或者服务端就会启动超时重传机制。这个超时重试的时间间隔是翻倍地增长的,1 秒、3 秒、7 秒、15 秒、31 秒、63 秒 ……。对于我们提供给用户直接访问的接口来说,重试第一次耗时 1 秒多已经是严重影响用户体验了。如果重试到第三次以后,很有可能某一个环节已经报错返回 504 了。所以在这种应用场景下,维护这么多的超时次数其实没有任何意义。倒不如把他们设置的小一些,尽早放弃。其中客户端的 syn 重传次数由 tcp_syn_retries 控制,服务器半连接队列中的超时次数是由 tcp_synack_retries 来控制。把它们两个调成你想要的值。

建议14:如果请求频繁,请弃用短连接改用长连接

如果你的服务器频繁请求某个 server,比如 redis 缓存。和建议 1 比起来,一个更好一点的方法是使用长连接。这样的好处有

1)节约了握手开销。短连接中每次请求都需要服务和缓存之间进行握手,这样每次都得让用户多等一个握手的时间开销。

2)规避了队列满的问题。前面我们看到当全连接或者半连接队列溢出的时候,服务器直接丢包。而客户端呢并不知情,所以傻傻地等 3 秒才会重试。要知道 tcp 本身并不是专门为互联网服务设计的。这个 3 秒的超时对于互联网用户的体验影响是致命的。

3)端口数不容易出问题。端连接中,在释放连接的时候,客户端使用的端口需要进入 TIME_WAIT 状态,等待 2 MSL的时间才能释放。所以如果连接频繁,端口数量很容易不够用。而长连接就固定使用那么几十上百个端口就够用了。

建议15:TIME_WAIT 的优化

很多线上服务如果使用了短连接的情况下,就会出现大量的 TIME_WAIT。

首先,我想说的是没有必要见到两三万个 TIME_WAIT 就恐慌的不行。从内存的⻆度来考虑,一条 TIME_WAIT 状态的连接仅仅是 0.5 KB 的内存而已。从端口占用的角度来说,确实是消耗掉了一个端口。但假如你下次再连接的是不同的 Server 的话,该端口仍然可以使用。只有在所有 TIME_WAIT 都聚集在和一个 Server 的连接上的时候才会有问题。

那怎么解决呢? 其实办法有很多。第一个办法是按上面建议开启端口 reuse 和 recycle。 第二个办法是限制 TIME_WAIT 状态的连接的最大数量。

# vi /etc/sysctl.conf
net.ipv4.tcp_max_tw_buckets = 32768
# sysctl -p

如果再彻底一些,也可以干脆直接用⻓连接代替频繁的短连接。连接频率大大降低以后,自然也就没有 TIME_WAIT 的问题了。


文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录