大家好,我是公众号「线下聚会游戏」作者HullQin,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。
背景
在专栏《Go WebSocket》里,有一些前置文章:
第一篇文章:《为什么我选用Go重构Python版本的WebSocket服务?》,介绍了我的目标。
第二篇文章:《你的第一个Go WebSocket服务: echo server》,介绍了一下怎么写一个WebSocket server。
第三篇文章:《单房间的聊天室》,介绍了如何实现一个单房间的聊天室。
第四篇文章:《多房间的聊天室(一)思考篇》,介绍了实现一个多房间的聊天室的思路。
第五篇文章:《多房间的聊天室(二)代码实现》,介绍了实现一个多房间的聊天室的代码。
第六篇文章:《多房间的聊天室(三)自动清理无人房间》,介绍了如何清理无人的房间,避免内存无限增长的问题。
如果你没阅读上面的文章,一定要先看一下,因为这篇文章更复杂,如果你不弄懂上面几篇,这篇可能跟不上节奏噢。
黑天鹅事件
回顾一下我们的goroutine架构图:
我用连线,表明了goroutine的启动关系:
- User连接WebSocket服务器时,会先启动
serveWs goroutine
。 - 在
serveWs goroutine
中,会执行register
操作,这一点之前的图中并没画出来。 - 随后
serveWs goroutine
启动了Read goroutine
和Write goroutine
,并结束自己。
此外,我们知道,同一个goroutine
内是按顺序执行的,多个goroutine
的执行顺序是没保证的,任何一个goroutine
都可能执行到某行代码时临时中断,去执行其它goroutine
的代码。
我们分析一下:
register和unregister竞争
可能性1
有没有这种可能?register执行到一半,在准备给h.clients增加一个client前,先执行了另一个client的unregister,这时候刚好是无人房间,然后房间被删了,但是执行到一半的register继续执行,给h.clients增加了一个client。导致了异常。
答案是:不可能。为什么?
因为处理register和unregister的hub是一个goroutine,它内部会不断轮询register、unregister、broadcast这几个channel。如果接收到某个,会把它处理完,再去处理下一个。所以针对同一个room,不存在register执行了一半,又去执行unregister的可能。
可能性2
有没有这种可能?serceWs执行到一半,发现现在某个房间存在且用户数=1,刚给register发送了数据,此时hub goroutine还没开始处理这个register的数据。但是另一个(房间内唯一的)客户端刚好同时断开了连接,给这个房间发送了unregister数据,hub优先处理了unregister的数据,把房间删除了。这时候hub goroutine结束了,之前的register channel也就被关闭了,数据被丢弃了,导致用户进入房间失败。
这里serveWs和hub是2个不同的goroutine,这种情况是可能发生的,只是需要一点点差运气,概率很低。
虽然概率低,但是绝不可忽略这种极端情况。如果你希望规模做大,必须至少在逻辑上保证100%的正确率。
毕竟我们现在写的不是coroutine协程,协程的执行顺序是开发者很容易通过async
和await
语法掌控的。goroutine的执行顺序并非完全由开发者掌控,需要通过channel、加锁,实现多个goroutine的顺序执行。
如何复现可能性2?
Go中有个重要的语句:runtime.Gosched()
,它可以让当前的goroutine暂停,退回执行队列,让其他等待的 goroutine运行,目的是为了使资源竞争的结果更明显。
我们可以代码中任意地方插入这个语句,看看是否符合预期。
此外,由于我们已经分析出了可能性2,所以有个更容易复现问题的办法:time.Sleep(time.Second * 5)
。在容易发生冲突的地方,休眠goroutine五秒钟,期间你可以执行使之冲突的操作,就100%复现问题了!
我也编写了相关代码,参考:github.com/HullQin/go-websocket-examples/commit/e5a5030a。
你可以按照这个步骤复现:
- 先进入
localhost:8080/123
,等待5秒中,直至创建房间完成。 - 再新开一个页面,进入
localhost:8080/123
,不要等5秒,立马执行下一步。 - 在5秒中内,关闭第一步的页面。此时,会打印
Found room
,但该房间已经关闭了,不符合预期。预期应该是Create room
。 - 等待5秒结束后,再新开一个Tab,同样进入localhost:8080/123。会打印
Create room
。而去此时这个页面和上一步打开的页面无法正常通信。
解决方案(易犯错)
加锁
通过给house
和hub.clients
加悲观锁,保证register和unregister不发生竞争。
在serveWs逻辑中,读取house
时,就加锁,直到hub.clients
更新完毕,释放锁。
在hub逻辑中,读取unregister时,也加锁,直到逻辑完毕时,释放锁。
代码实现
可参考:github.com/HullQin/go-websocket-examples/commit/e6d32cd4。
增加全局变量:
import "sync"
var mutex sync.Mutex
验证
如果你简单这么写,其实会陷入死锁困境。
因为当43行代码才解锁,但是45行可能先被执行。在45行执行时,hub goroutine就阻塞了,43行代码永远没机会执行了。
之后你会发现消息发不出去了,因为broadcast
也被阻塞了。
最终解决方案
可参考:github.com/HullQin/go-websocket-examples/commit/686a449a。
思考
不要自己阻塞自己,不要让某个goroutine自己给自己解锁,要在其它goroutine解锁。
所以,我们把register逻辑删掉,不通过channel发送了,直接在serveWs实现这个注册逻辑,并解锁:
hub.go
删除register channel即可:
验证
大功告成!这次,第二个进入后,第一个立马关闭时,不会立即删除。而是等第二个进入流程完毕后,再删除房间。
写在最后
我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。