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

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

在一个位置上盯住多个套接字

在 TT Lab 中继续学习

一句话总结

多路复用调用可以一次监视多个套接字,但“已就绪”这个答复,只意味着现在去读也不会被阻塞。

为什么需要它

要让一个连接不阻塞其余连接,就不能给每个连接各设一个等待的位置,而要把等待集中到一处。给每个连接分配一个线程也是一种办法,但每个线程都有自己的栈,还有上下文切换的开销,如果目标是成千上万个连接,就会先碰到另一类极限。多路复用是反方向的做法:执行流保持一个,只把等待的事交给内核。

工作原理

程序把套接字列表,以及对每个套接字想知道什么,告诉内核:是出现了可读的内容,还是出现了可写的空位。内核会让程序睡眠,直到其中任何一个条件满足,并在唤醒时只返回条件已满足的那些。所以无论有多少连接,等待的位置只有一个。

Linux 中有好几个调用负责这件事,越老的调用,限制越多。select(2) 在文档第一行就加了警告:在 glibc 实现中,fd_set 是固定大小,所以只能监视编号小于 FD_SETSIZE(即 1024)的描述符,而且这个限制不会改变,因此新的应用应当使用 poll 或 epoll。epoll(7) 在内核中分别维护关注列表和就绪列表,因此被设计成即使监视的描述符变多,也能很好地扩展。

在 Python 中,不必自己去选择这种差别。selectors 模块的 DefaultSelector 会选出该平台上最高效的实现。程序要处理的概念缩减为三个。

selector.register(sock, selectors.EVENT_READ, data)
for key, events in selector.select(timeout=1.0):
    if events & selectors.EVENT_READ:
        ...
selector.modify(sock, selectors.EVENT_READ | selectors.EVENT_WRITE, data)

这里最常被误解的是“已就绪”这句话的含义。读就绪不是说有数据,而是说现在尝试读取也不会停住。对方关闭连接时,同样会以读就绪被唤醒,而这时 recv 返回的是 0 字节。0 字节不是空消息,而是表示再也不会有数据到来的信号。如果把这两者混为一谈,就会变成一个始终盯着已断开的连接、循环不停空转的程序。

更进一步,select(2) 的 BUGS 一节写道,对于被报告为读就绪的描述符,接下来的读取仍然可能被阻塞。所举的例子是数据已经到达,但经检查校验和不匹配而被丢弃的情形,文档因此建议,对于不允许阻塞的套接字,最好使用 O_NONBLOCK。总结起来,多路复用并不是替代非阻塞,而是与它搭配使用。就绪通知决定什么时候去尝试,非阻塞则保证在尝试落空时不会让程序停住。

醒来之后要做的事也有规则。如果监听套接字因读就绪而被唤醒,并不能保证等待的连接只有一个。一次唤醒中,队列里可能有多个连接,所以必须反复调用,直到 accept 返回 EAGAIN 或 EWOULDBLOCK,把队列清空。同一份文档还写明,对于已经断开的连接可能出现 ECONNABORTED。不能因为这一个,就放弃其余的等待队列。

在现场相遇的样子

事故通常表现为 CPU 使用率 100%。连接并没有增加,事件循环却不停地空转。顺着原因追查,通常是两者之一:要么没有把已断开的连接从关注列表中移除,要么在没有要发送的内容时仍然在监视写就绪。可写的位置几乎总是空着的,所以写就绪几乎总是为真,于是 select 会立即返回。下一个模块会正面处理这个问题。

也有反方向的事故:在处理一个事件的同时,进行了另一个阻塞调用。比如同步读取文件、以阻塞方式向其他服务发送请求,或者就地进行繁重的计算。多路复用服务器只有一个执行流,所以这一次停顿,就成了正在监视的所有连接的停顿。改变结构并不会让阻塞消失,而是让阻塞变得贵得多。

下一项测验要做什么

请区分“已就绪”的通知保证什么、不保证什么。测验的内容是:在因读就绪被唤醒的套接字上 recv 返回 0 的情形、监听套接字被唤醒一次时有三个连接在等待的情形,以及写就绪一直为真的情形,各应如何处理。