« ltask :Lua 的多任务库 | 返回首页 | fbx 到 gltf 转换问题 »

skynet 处理 TCP 连接半关闭问题

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_READHALFCLOSE_WRITE 两个状态(和操作系统内核处理半关闭状态一致)。而且,我还需要区分半关闭状态是服务器主动进行的,还是对端操作的。

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

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

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

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

Comments

"读已经关闭.那读都已经关闭了,我们怎么又有可读事件发生"

事件是 epoll 批量获得的,不等于关闭读事件,已经获得的事件中就没有可读事件存在。

即使上面的结论是存疑的,我也认为这个问题不值得推敲。作为一个函数 forward_message_tcp 局部, 函数的职责应该局限在函数内部,尽可能的处理全面的情况。如果依赖上下文的依赖来确定里面的状态,必然对阅读造成困扰,需要书写更多的依赖条件。

程序第一优先是阅读简单。如果可以(依赖更多的外部上下文)去掉这个判断,获得的收益实在太小;而加上这个判断,并没有明显损失。即使要去掉这个 if ,我也倾向于改成 assert(t != SOCKET_TYPE_HALFCLOSE_READ) , 且不应该在运行时关闭 assert 。

补充一点 我说的判断 s->type为SOCKET_TYPE_HALFCLOSE_READ是指定下面的 1452行 https://github.com/cloudwu/skynet/blob/master/skynet-src/socket_server.c

你好,我有一个关于forward_message_tcp函数在处理可读事件时的疑问。代码如下所示: https://github.com/cloudwu/skynet/blob/master/skynet-src/socket_server.c

具体我的疑问是 为什么 当recv 0时,要判断 s->type 是否为 SOCKET_TYPE_HALFCLOSE_READ ?
1.如果之前我们已经主动关闭读,在等待剩余的数据发送出去.那么此时s->closing 是ture,那么肯定不会有机会去判断 s->type.
2.如果之前是被动关闭的,即之前已经recv 0了,读已经关闭.那读都已经关闭了,我们怎么又有可读事件发生,来调用forward_message_tcp函数来处理呢?

文中上行下行的概念弄反了

一直在关注

博客好久更新了,风哥,游戏界技术泰斗,望一腔热忱,慎终如始。

感谢分享 给你一个赞

云风大才

@dwing 所以 @菜鸟浮出水 提到了 tcp_ip_abort_interval。个人理解自身实现心跳的主要原因在于网络通畅不代表程序在正常运行。

是这样,我在跟别人讨论关于快手(aauto,一个国产编程工具)的原理问题,网上有一篇2014年写的关于aauto编译的exe程序中有Lua虚拟机的逆向分析文章,这篇文章多个链接指向了你的这个博客,我以为是你写的。

@菜鸟浮出水
send导致无ACK响应并尝试重发, 最终会触发socket异常状态. 但这个时间间隔并不确定, 有的环境十几分钟也有可能. 改协议栈参数并不跨平台, 而且未必有权限修改, 不如应用自己定义更可靠.
其实这个问题已经不是收到FIN包的响应问题了, 而是任何情况下都可能要考虑, 否则对方一旦丢失(如对方拔网线,既无FIN也无RST发来), 可能需要很久服务器才能释放连接, 会导致很多资源浪费, 当然是比较极端的情况才需要关注.
对网络游戏而言, 通常是用心跳机制确保连接有效, 而心跳机制本身就需要有定时器.

@dwing 如果收到了对方的FIN,继续往tcp写,对方无响应,需要本端计时吗?这里的计时感觉应该还是依赖tcp超时重传机制比较合理吧。毕竟即使收到了FIN,也不能保证后续发送的数据就一定是因为对端关闭了不可达导致无响应的,依然存在网络拥塞导致的无响应,还是应该让tcp超时重传一直到自己abort吧,linux一般是2min,当然如果觉得过长可以设置tcp_ip_abort_interval吧。根据业务的需求自己做这种计时,我感觉意义不大。

读了一些文章并做了些测试得出结论:
对方shutdown(写)还是close(),都是发来FIN包,无法区分.
如果本端直接close()那就无所谓了.
如果还有数据需要发送,下次send()时,对方如果回了RST包,才说明对方close了. 如果对方没响应,那么本端需要计时,发现一定时间内send的进度没有变化,才能断定连接丢失,并放心close.

@rawa9999

我不知道 aauto 是什么。

@dwing

我是这样理解的:

如果一方调用了 shutdown SHUT_RD , 那么它只是在本地协议栈变更了 fd 的状态,并不会做别的事情。

如果调用了 shutdown SHUT_WR , 那么除了变更本地 fd 的状态,还会向对端发送一个 FIN 。

所以,当服务端 recv 0 , 那么只说明客户端发送了 FIN (shutdown 写),是否 shutdown (读) 是不确定的。此时, fd 依然是可以继续写的。

但是,如果服务器发送的数据达到的时候(无论发出的时候是什么状态,有没有收到过 FIN ),客户端的状态已经是不读(shutdown 读),客户端会发出一个 RST ,这会让服务器产生 SIGPIPE / EPIPE 。

也就是说:在 API 层面,如果我方没有主动关闭写(发送 FIN ),判断对方还会不会读 fd 的标准应该是 write 后是否出错(EPIPE)。另外,如果 socket 已经完全关闭(走完了关闭流程),那么 epoll 还会产生 EPOLLHUP 事件。这个事件可能在 recv 0 之后收到。

@rawa9999 完全不知道你在说神马。。。

还记得吗,我第一次回复你,应该是在2006年~2007年,用的是rawa459的名字,我说写一个编译器,或者解释器的问题,到今天为止,虽然没有成品,不过略有小成。

还有,他为什么不使用其他的东西来转化pascal代码呢?只有一个答案,Lua小,并且快,并且Lua可以高速的在内存中处理位图,就是把一些位图的块,高速的从内存中拷贝和输出到IO中,这是Lua最擅长的事,并且处理大长度的字符串更是不在话下,这样,就是用Lua虚拟机的前提,这事并不复杂。

我认真看了,你那篇文章,还有后来关于他反驳你的回复,如果想使用Lua实现一个class,这个难度比写个新语言还难,他根本没这个能力,况且Lua你用了这么多年,就是还有最近codedump(这个人应该是个老外,现在在中国捞金)的新书,lua主要是解决C++和C之间的沟通问题的,类和对象交给C++去做就很好了,沟通问题交给Lua。当然了,Lua可以实现class,他没这个能力,我不是小看他,另外lua也没这个必要,除非写一门新语言,比如luajit之类,加上这个东西。

写一个实用的编译器,这事其实是计算机天花板上的程序员能做的事,相当的复杂。

那篇aauto和Lua的关系的帖子是你写的?事实上,你想的复杂了,根据我的分析,这个玩意,就是使用lua虚拟机,把大幅的源代码,按照非常简单的转换格式,原封不动的转化为delphi的源码,然后交给delphi的解释器,这个开源版本有个pascal解释器,或者再编译成exe的,如果真像你分析的那么复杂,至少他能看懂lua的源代码,并且深入的理解lua,现实是他连门都没入,不复杂。

服务端read到0后, 如何判断客户端是shutdown(写)还是close了?
如果此时业务处理还没来得及给客户端发送数据, 是不是会误以为应该close了?

Post a comment

非这个主题相关的留言请到:留言本