TT Lab
开始
学习 学习路径 课程

一个连接变慢,其余全都停住了

一个人让整条队伍停下

在 TT Lab 中继续学习

一句话总结

在顺序服务器中,只要有一个连接迟迟不开口,排在它后面的连接就全都要陪着一起等这段时间。

为什么需要它

在谈论成千上万个连接之前,应该先看看两个连接是如何互相阻塞的。最常见的服务器形态是一个循环:用 accept 接收一个连接并处理完毕,再回到 accept。这种结构易于阅读,客户端一个接一个依次到来时不会有任何问题。问题并不是在客户端同时到达时暴露的,而是在前一个客户端很慢时暴露的。

这里的“慢”分为两种。一种是服务器处理该请求花费很长时间,另一种是客户端迟迟不开始说话。第二种更危险。因为服务器其实什么都没做,整个进程却停在 recv 里,等着对方发来第一个字节。这时 CPU 使用率接近 0,日志里也不会留下任何一行。忙碌与被阻塞,从表面指标上几乎看不出区别。

如果说前一门课程处理的是字节边界和重新连接,那么这次要处理的是更靠前的问题:一个进程要同时持有多个连接,需要改变什么。

工作原理

套接字调用中可能停住的是固定的几个。在阻塞模式下,下面三个调用都会让进程停住。

调用 什么时候停住 这期间其他连接
accept 监听队列为空时 无法接收新连接
recv 没有收到字节时 已接收的连接也无法读取
send 没有可发送的空间时 无法发送响应

新连接并不会彻底消失。内核会把完成了 3-way handshake 的连接放进监听队列,等到 accept 被调用时再逐个交出去。因此在客户端看来,connect 似乎立刻成功了,只是迟迟收不到响应。在界面上表现为“能连上,但很慢”。

这个队列的大小就是 listen 的 backlog。listen(2) 写明,如果这个值大于 /proc/sys/net/core/somaxconn,就会被悄悄截断为后者。根据同一份文档,从 Linux 5.4 起该文件的默认值是 4096,在此之前的内核中是 128。也就是说,即使在代码里写了一个很大的数字,实际的队列大小也由内核设置决定。而且,加大队列只是增加了等待者的数量,并不会缩短等待的时间。

要先测量。先接入一个慢客户端,再让六个快客户端排在后面,在顺序服务器中,这六个客户端都要等上和慢客户端一样长的时间。实验中使用的测量工具会这样给出这些数字。

client 1: 1699 ms OK
...
blocked=6
fast=0

blocked 是超过 1 秒的客户端数量,fast 是在 0.2 秒内完成的客户端数量。改成多路复用后,在相同负载下这两个数字会反过来。如果改动之前不先测量,就无法说明哪里变好了,也无法发现没有变好的情况。

阻塞本身并不是糟糕的设计。Python 套接字 HOWTO 也是从阻塞套接字开始讲解的。只是在阻塞模式下,一次只能处理一个连接;要同时处理多个连接,就必须给每个连接单独分配执行流(线程、进程),或者改用不会停住的调用。本课程走的是第二条路。

给每个连接分配一个线程的路径在实际中也很常用。它有一个很大的优点:代码保持阻塞的样子,所以容易阅读。不过每个线程都有自己的栈,上下文切换也有开销,如果目标是成千上万个连接,那么先碰到极限的是资源而不是吞吐量。更重要的是,这两条路是用不同的方式解决同一个问题。线程把等待的位置增加到与连接数相同,多路复用则把等待的位置集中到一个。无论哪一种,核心都是一样的:让一个连接的等待不会阻碍其他连接的推进。

在现场相遇的样子

故障报告会以这样的形式出现:服务器 CPU 很空闲,却只有响应时间的尾部很长,重启之后短暂好转,随后又变差。健康检查却是通过的。因为发送健康检查的一方是一个连上就立刻发出请求的老实客户端,轮到它时就能很快得到答复。

原因通常是几个慢客户端。比如在移动网络上请求头到得很晚,客户端只是提前把连接打开、过后才使用,或者恶意地一次只发一个字节。在顺序服务器中,这样的一个客户端就能把整个服务的吞吐量绑定在它自己的速度上。只看平均延迟,是看不到这种情况的。被阻塞的人们所花的时间,藏在了平均值的背后。

下一项测验要做什么

请自己画出时间线。如果慢客户端在 2 秒后才开始说话,而六个快客户端在 0.3 秒时就到达了,那么在顺序服务器中,这六个客户端的响应会在什么时候发出?它们的 connect 又是在什么时候成功的?这两者发生在不同的时刻,正是本模块的核心。测验中会确认:哪些调用会让进程停住,监听队列能延后什么、不能延后什么,以及为什么调大 backlog 并不是解决办法。