这种做法崩塌的地方
一句话总结
一个进程能承受的连接数,由文件描述符上限、每个连接占用的内存以及一个核心决定,这三者都可以测量确认。
为什么需要它
改成多路复用之后,即使连接数突然增加,看起来也能撑住。于是人们不再追问极限在哪里,而极限往往是在最忙的那一天才第一次被发现。这个模块把极限分成三类,提前数一数。这三类都不是靠猜测,而是用一行命令就能确认的值。
工作原理
第一是文件描述符上限。根据 getrlimit(2),RLIMIT_NOFILE 表示该进程可以打开的最大描述符编号加 1,试图超过它会以 EMFILE 失败。一个连接占用一个描述符,所以这个值就是并发连接的天花板。自己进程的值可以用 ulimit -n 查看,实际持有多少个,可以数一数 /proc/<pid>/fd。
这里被统计的并不只有连接。标准输入输出三个、监听套接字一个,如果多路复用对象的实现也使用描述符,还要再加上那一个。所以接上 200 个连接之后数出来的值,不是 200,而是比它稍大一点。这个小小的差别之所以重要,是因为只有能解释这个差别,才能预测在接近上限时,是什么先耗尽。
第二是监视方式本身的限制。如前所述,select(2) 在 glibc 实现中,只能处理小于 FD_SETSIZE(即 1024)的编号。无论把描述符上限提得多高,只要使用 select,就无法监视编号超过 1023 的描述符,文档也写明此时应使用 poll 或 epoll。使用 Python 的 DefaultSelector 时,这个选择由平台代劳,但选中了哪种实现,是值得确认一下的。
第三是每个连接占用的内存。其中不仅有程序创建的状态,还有内核的套接字缓冲区。tcp(7) 说明,套接字缓冲区的大小由 /proc/sys/net/ipv4/tcp_rmem 和 tcp_wmem,或每个套接字各自的 SO_RCVBUF 和 SO_SNDBUF 来确定,同时写明 TCP 实际分配的是所请求大小的两倍,多出来的空间用于管理结构。所以用 getsockopt 重新读出的值与所设置的值并不相同。计算一个连接所占的空间时,如果漏掉这个倍数,预算就会偏差一半。
| 限制 | 由什么决定 | 如何确认 |
|---|---|---|
| 并发连接数 | RLIMIT_NOFILE | ulimit -n,数 /proc/PID/fd |
| 可监视的编号 | select 的 FD_SETSIZE | 文档中为 1024,epoll 不适用 |
| 每个连接的内存 | 套接字缓冲区与程序状态 | tcp_rmem 与实际使用量 |
还有第四个。事件循环只有一个执行流,所以只使用一个核心。连接增多、CPU 把一个核心占满之后,即使描述符和内存都还有富余,延迟也会增加。这时的解决办法不是优化循环,而是增加进程、使用更多核心。所以真实的服务会同时使用多路复用和多进程。本课程讲的是其中的前一半。
在现场相遇的样子
同样的代码在笔记本上能跑,在容器里却不行,这种情况经常源自这类限制。描述符上限是每个进程各自带有的值,所以运行环境一变,它也跟着变,而触及上限时出现的 EMFILE,通常不是在接收连接的位置,而是在毫不相干的地方先爆发。打开文件或轮转日志的代码会先失败,于是原因出在网络上这一事实,很晚才暴露出来。
内存方面的事故更加悄无声息。每个连接几 KB 看起来很小,但到了几万个就是 GB 级别,而这部分空间不在应用堆里,而在内核一侧,所以在语言运行时的内存指标中不容易体现出来。于是就出现了进程的常驻内存看起来很平常、节点却受到压力的局面。
所以制定容量规划时,先确定目标连接数,再把这个数分别与描述符上限、每个连接占用的空间联系起来演算一遍。两者中先触及的那一个,才是这项服务真正的极限。把数字记录下来,之后触及极限时就清楚该扩充什么,更重要的是,在触及极限之前就能知道。
下一项实验要做什么
现在开始亲手构建。先用顺序服务器测量排队情况,再用多路复用服务器承受同样的负载,对比这两个数字。最后接上 200 个连接,数一数服务器进程持有的描述符,并且要能说明这个值为什么不是 200。实验的评分器会当场重新测量并核对你写下的数字,所以随便写一个看似合理的值是通不过的。