我是公众号线下派对游戏的作者HullQin(欢迎关注公众号,发送加微信,交个朋友),转发本文需要获得作者HullQin的授权。我独立开发了《联机桌游合集》,这是一个网页,在这里你可以轻松地和朋友一起玩网络游戏,五子棋等游戏,不收费,也没有广告。还为GameJam2022开发了《Dice Crush》,喜欢的话可以关注我HullQin哦~有空我会分享制作游戏的相关技术。背景《Go WebSocket》专栏,有前几篇文章:第一篇:《为什么我选用Go重构Python版本的WebSocket服务?》,介绍我的目标。第二篇文章:《你的第一个Go WebSocket服务: echo server》,介绍了如何编写WebSocket服务端。第三篇:《单房间的聊天室》,介绍如何实现单间聊天室。第四篇:《多房间的聊天室(一)思考篇》,介绍实现多房间聊天室的思路。第五篇:《多房间的聊天室(二)代码实现》,介绍实现多房间聊天室的代码。第六篇:《多房间的聊天室(三)自动清理无人房间》,介绍如何清理一个无人居住的房间,避免内存无限增长的问题。如果你还没有读过上面的文章,你必须先阅读它,因为这篇文章比较复杂。如果以上文章看不懂,这篇文章可能跟不上节奏。黑天鹅事件回顾一下我们的goroutine架构图:我用connection来表示goroutine的启动关系:当User连接到WebSocket服务器时,会先启动serveWsgoroutine。在serveWsgoroutine中,会进行register操作,这个在上图中没有画出来。然后serveWsgoroutine启动Readgoroutine和Writegoroutine,然后自己结束。另外,我们知道同一个goroutine是顺序执行的,多个goroutine的执行顺序是不保证的。任何一个goroutine都可能在执行某一行代码时被临时中断,去执行其他goroutine代码。我们来分析一下:注册和注销竞争可能性1有没有可能?在register执行到一半的时候,在h.clients中添加一个client之前,先执行了另一个client的unregister。这时候刚好是一个没人住的房间,然后把房间删除了,但是中途register继续执行,给h.clients增加了一个client。导致异常。答案是:不可能。为什么?因为处理注册和注销的hub是一个goroutine,它会不断轮询注册、注销和广播的通道。如果收到一个,将在处理下一个之前对其进行处理。所以对于同一个房间,不可能先注册一半再注销。可能性2有这种可能性吗?serceWs执行到一半,发现某房间存在,用户数为1,数据刚发送到寄存器。这个时候hubgoroutine还没有开始处理寄存器的数据。但是另一个客户端(房间里唯一的一个)同时断开连接并向这个房间发送注销数据。集线器首先处理注销数据并删除房间。此时hubgoroutine结束,之前的register通道关闭,数据被丢弃,导致用户进房失败。这里的serveWs和hub是两个不同的goroutine。这种情况是有可能的,只是需要一点点运气,而且概率很低。虽然概率很低,但这种极端情况绝不能忽视。如果要规模化,至少在逻辑上要保证100%的正确性。毕竟我们现在写的不是coroutine协程。开发人员可以通过async和await语法轻松控制协程的执行顺序。goroutines的执行顺序并不完全由开发者控制。需要通过通道和锁来实现多个goroutine的顺序执行。如何重现可能性2?Go中有一个重要的语句:runtime.Gosched(),可以挂起当前goroutine,返回执行队列,让其他等待的goroutine运行,目的是让资源竞争的结果更加明显。我们可以在代码的任意位置插入这条语句,看看是否符合预期。另外,既然我们已经分析了可能性2,那么有一个更容易重现问题的方法:time.Sleep(time.Second*5)。在容易发生冲突的地方,sleepgoroutine5秒,期间可以进行引起冲突的操作,问题100%重现!我也写了相关代码,参考:github.com/HullQin/go-websocket-examples/commit/e5a5030a。可以按照这个步骤重现:先输入localhost:8080/123,等待5秒,直到房间创建完成。打开一个新页面,输入localhost:8080/123,不要等待5秒,立即执行下一步。5秒内,关闭第一步的页面。此时,Foundroom打印出来了,但是房间是关闭的,不符合预期。期望应该是创造空间。等待5秒后,打开一个新的Tab,输入localhost:8080/123。创建房间将被打印。此时该页面无法与上一步打开的页面正常通信。解决方案(容易出错)通过给house和hub.clients加悲观锁来保证register和unregister不会竞争。在serveWs逻辑中,当读取到house时,锁被锁住,直到hub.clients被更新,锁被释放。在hub逻辑中,在读取unregister的时候,也会加锁,直到逻辑完成后才会释放锁。代码实现请参考:github.com/HullQin/go-websocket-examples/commit/e6d32cd4。添加全局变量:import"sync"varmutexsync.Mutexverification如果单纯这样写,其实会陷入死锁。因为43行代码时解锁,但45行可能先执行。当第45行执行时,hubgoroutine被阻塞,第43行将永远没有机会执行。之后你会发现消息发不出去,因为广播也被屏蔽了。最终的解决方案可以参考:github.com/HullQin/go-websocket-examples/commit/686a449a。想想自己不堵,不要让一个goroutine自己解锁,而是在其他goroutine中解锁。因此,我们删除注册逻辑,不通过通道发送,直接在serveWs中实现注册逻辑,解锁:hub.go删除注册通道:验证完成!这次当第二个进入后第一个立即关闭时,不会立即删除。相反,在删除房间之前等待第二个进入过程完成。最后,我是公众号线下派对游戏的作者HullQin(欢迎关注公众号,发送加微信,交友),转载本文需作者HullQin授权。我独立开发了《联机桌游合集》,这是一个网页,在这里你可以轻松地和朋友一起玩网络游戏,五子棋等游戏,不收费,也没有广告。还为GameJam2022开发了《Dice Crush》,喜欢的话可以关注我HullQin哦~有空我会分享制作游戏的相关技术。
