skynet 处理 TCP 连接半关闭问题

云风 2021-02-27 11:58

TCP 连接是双工的,既可以上行数据,又可以下行数据。连接断开时,两侧通道也是分别关闭的。

从 API 层面看,如果 read 返回 0 ,则说明上行数据已经关闭,后续不再会有数据进来。但此时,下行通道未必关闭,也就是说对端还可能期待收取数据。

同样,如果 write 返回 -1 ,错误是 EPIPE ,则表示下行通道已经关闭,不应再发送数据。但上行通道未必关闭,之后的 read 还可能收到数据。

用 shutdown 指令可以主动关闭单个通道(上行或下行)。

如果 tcp 连接只有一侧关闭,我们(skynet 中)称之为半关闭状态。在最开始,skynet 是将半关闭视为关闭的。这是因为,skynet 一开始只考虑网络游戏应用。在网络游戏中,连接被视为不可靠的,任何时候客户端都应该妥善处理服务器断开的情况,而服务器也应该处理客户端不告而别。业务层一般会额外做一套握手协议,不完全依赖 tcp 的底层协议。状态一般是不在客户端保存的。如果上一次链接上有数据没有发送到,那么下一次建立连接会重新拿取必要的数据。

所以,在 read 返回 0 ,或是 write 出错后,skynet 的底层都直接 close 连接。这种简单粗暴的方法可以大大简化底层的实现复杂度。同时,如果需要确保业务层数据交换的可靠性,就在业务层增加确认机制。见 issue 50

随着 skynet 应用领域的扩展,我发现在很多场合依赖已有的协议(不方便增加确认机制)时,必须更好的处理半关闭状态。比如,服务器想推送很多数据然后关闭连接。如果业务层不提供客户端确认收到的机制,就很难正确实现。只是简单的 write write …… close ,很可能在底层还没有全部发送完数据前就先把连接 close 了。

这时,skynet 增加了一个简单的半关闭处理机制。如果主动调用 close 时发现之前还有从业务层写出的数据并没有发送完,那么就让 fd 进入半关闭状态。半关闭状态下,fd 不可读写,从业务层看已经关闭,但底层会一直保留 fd 到数据全部发送完毕或发送失败(对端关闭)。

这个机制良好运作了好几年,直到有很多基于 skynet 的 web server 的应用场景出现。

我们发现,某些浏览器在从 web server 下载文件时会有这样的行为:发送一个 http 请求,然后立刻关闭 write 但保留 read 。也就是说,在 web server 看,读完请求后,会 read 到 0 。如果此时服务器主动关闭连接,后续就不再能写出回应包。由于 read 的行为是 skynet 底层的网络线程主动进行,而不是业务层调用 read 触发的,所以业务层无法控制它。

为解决这个问题,我在 2021 年初提交了一个 pr能更妥善的处理半关闭问题。在实现过程中我发现,如果我想保持兼容,并不把复杂性甩给上层(让业务层可以自己小心的单边关闭连接)。那么,我需要更仔细的处理半关闭的情况。底层使用一个半关闭状态值是不够的,必须区分HALFCLOSE_READ和HALFCLOSE_WRITE两个状态(和操作系统内核处理半关闭状态一致)。而且,我还需要区分半关闭状态是服务器主动进行的,还是对端操作的。

主动进入半关闭状态比较简单:业务层不允许只关闭一半的信道(读或写),但它在关闭时,之前还有推送到底层的下行数据没有发送完,就需要进入半关闭状态,调用 shutdown 关闭读,但还需要等待数据发送完毕。

被动进入半关闭状态需要分为两种:read 到 0 或 write 出现 EPIPE 错误。分别应该设为不同的半关闭状态。

由于我在这方面经验不足,这次的修改遗留了很多 bug 。感谢 skynet 的广泛应用,很快就收到了反馈。这两周一直在修复实现问题。具体可以看issue 1346以及相关讨论。

现在的版本暂时没有新的 bug 报告。已发现的 bug 很多是对 epoll 理解不够,kqueue 也和 epoll 有许多细微的差别。仔细阅读手册,查看 stackoverflow 的相关问题,对理解它们帮助很大。例如:TCP: When is EPOLLHUP generated?

[返回] [原文链接]