什么时候断开沉默的连接
一句话总结
仅看套接字,无法区分安静的连接和已死的连接,所以程序必须自己记下最后一次活动的时刻。
为什么需要它
多路复用服务器本来就是设计成长时间持有连接的。这既是优点,也是问题。客户端发出请求、收到答复之后如果什么都不说,服务器仍会一直盯着这个连接。一个文件描述符、接收和发送缓冲区,以及程序为每个连接创建的状态,都会原样保留。这样的连接堆积起来,到了某个时刻就无法再接收新连接,而这时看到的错误通常是资源不足,指向的位置离真正的原因相去甚远。
更糟的情况是对方已经消失了。合上笔记本盖子,或者中间的设备忘记了状态,对方就既发不出 FIN 也发不出 RST。服务器一侧的套接字依然开着,读就绪永远不会到来。这种状态称为 half-open。仅仅查询套接字的状态,无法判断它是安静还是已死。
工作原理
解决办法是不去询问套接字,而是记录时间。为每个连接记下最后一次发生有意义的事情的时刻,并定期与当前时刻比较,把过旧的连接断开。
conn.last_active = clock() # 읽거나 보낸 뒤에 갱신
stale = [key for key, conn in conns.items()
if now - conn.last_active >= idle_timeout]
需要确定三件事:把什么算作活动,多久检查一次,以及使用哪种时钟。
活动的定义因服务而异。可以只把收到字节算作活动,也可以把发出响应也包括在内。重要的是把定义集中在一处。如果在多个地方各自更新时刻,之后就无法解释为什么这个连接没有被断开。
检查周期与多路复用调用的等待时间绑定在一起。如果一个事件都没有,程序就在 select 里睡着,所以无论制作多么准确的过期列表,只要不醒来,就没有任何连接会被断开。因此要给等待时间设定上限,让程序即使没有事件也会定期醒来,把所有连接扫一遍。
时钟是用来测量经过的时间的,所以要使用单调时钟,而不是挂钟。如果因为时间同步,挂钟被向回调整,尚未过期的连接就可能看起来已经过期,反之也可能永远不过期。如果把时钟设计成可以注入的,就能不必真的等待几秒,也能测试过期判断。
内核中也有类似的机制。tcp(7) 和 socket(7) 所说明的 TCP keepalive,会向长时间安静的连接发送空探测包,以确认对方是否还活着。不过这只是传输层的存活确认,而不是应用层的空闲策略。即使对方进程已经卡住,内核仍然可以应答,所以 keepalive 能通过,而业务依然没有进展。这两种机制不能互相替代。
断开连接时有一定的顺序。先从关注列表中移除,再清除程序持有的该连接的状态,最后关闭套接字。如果只关闭套接字而把它留在列表中,下一次醒来时就会去找一个不存在的连接;反过来,如果只从列表中移除而不关闭,描述符就会泄漏。实验中把这套清理集中在一个函数里。
把挑选过期对象和真正断开分成两步,也是出于同样的原因。如果一边挑选一边删除,遍历过程中数据结构发生变化,某些连接就会被跳过。更实际的原因是测试。如果挑选函数是纯函数,只要传入时钟就能确认边界条件,不需要真的去打开套接字或等待几秒。像“经过时间恰好等于限制时是否断开”这样的问题,只有这样才能把答案固定下来。
清理之后记录日志时,要同时写明原因。对方关闭而断开的,与因空闲限制由我们断开的,原因完全不同,如果日志里只写“连接终止”,之后就没有办法区分二者。生产环境中判断是否要调整空闲限制,依据的正是这种区分。
在现场相遇的样子
在生产环境中经常看到这样的组合:连接数曲线没有锯齿,单调地上升,而每秒请求数与平时一样。也就是说,新客户端不断到来,而旧客户端不离开。这样的曲线出现在没有空闲清理,或者有空闲清理但在没有事件时不会醒来的结构中。
反方向的事故也很常见:空闲限制设得太短。本来被设计成复用连接的客户端,每次都要新开连接,延迟增加,端口也被迅速消耗。因此这个数值要结合客户端的复用周期来确定。只看服务器而选定的空闲限制,通常会在某一侧出错。
下一项测验要做什么
请整理一下:用什么来区分连接是安静还是断开,一个事件都没有时过期检查是如何运行的,以及 keepalive 保证什么、不保证什么。实验中要构建的 idle_keys 只负责挑选而不负责删除,也不妨一并想想其中的原因。