大家好,我是公众号「线下聚会游戏」作者HullQin,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。

背景

在专栏《Go WebSocket》里,有一些前置文章:

第一篇文章:《为什么我选用Go重构Python版本的WebSocket服务?》,介绍了我的目标。

第二篇文章:《你的第一个Go WebSocket服务: echo server》,介绍了一下怎么写一个WebSocket server。

第三篇文章:《单房间的聊天室》,介绍了如何实现一个单房间的聊天室。

第四篇文章:《多房间的聊天室(一)思考篇》,介绍了实现一个多房间的聊天室的思路。

第五篇文章:《多房间的聊天室(二)代码实现》,介绍了实现一个多房间的聊天室的代码。

第六篇文章:《多房间的聊天室(三)自动清理无人房间》,介绍了如何清理无人的房间,避免内存无限增长的问题。

如果你没阅读上面的文章,一定要先看一下,因为这篇文章更复杂,如果你不弄懂上面几篇,这篇可能跟不上节奏噢。

黑天鹅事件

回顾一下我们的goroutine架构图:

image.png

我用连线,表明了goroutine的启动关系:

  • User连接WebSocket服务器时,会先启动serveWs goroutine
  • serveWs goroutine中,会执行register操作,这一点之前的图中并没画出来。
  • 随后serveWs goroutine启动了Read goroutineWrite 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协程,协程的执行顺序是开发者很容易通过asyncawait语法掌控的。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

你可以按照这个步骤复现:

  1. 先进入localhost:8080/123,等待5秒中,直至创建房间完成。
  2. 再新开一个页面,进入localhost:8080/123,不要等5秒,立马执行下一步。
  3. 在5秒中内,关闭第一步的页面。此时,会打印Found room,但该房间已经关闭了,不符合预期。预期应该是Create room
  4. 等待5秒结束后,再新开一个Tab,同样进入localhost:8080/123。会打印Create room。而去此时这个页面和上一步打开的页面无法正常通信。

image.png

解决方案(易犯错)

加锁

通过给househub.clients加悲观锁,保证register和unregister不发生竞争。

在serveWs逻辑中,读取house时,就加锁,直到hub.clients更新完毕,释放锁。

在hub逻辑中,读取unregister时,也加锁,直到逻辑完毕时,释放锁。

代码实现

可参考:github.com/HullQin/go-websocket-examples/commit/e6d32cd4

增加全局变量:

import "sync"
var mutex sync.Mutex

image.png

image.png

验证

如果你简单这么写,其实会陷入死锁困境。

因为当43行代码才解锁,但是45行可能先被执行。在45行执行时,hub goroutine就阻塞了,43行代码永远没机会执行了。

之后你会发现消息发不出去了,因为broadcast也被阻塞了。

最终解决方案

可参考:github.com/HullQin/go-websocket-examples/commit/686a449a

思考

不要自己阻塞自己,不要让某个goroutine自己给自己解锁,要在其它goroutine解锁。

所以,我们把register逻辑删掉,不通过channel发送了,直接在serveWs实现这个注册逻辑,并解锁:

image.png

hub.go删除register channel即可:

image.png

验证

大功告成!这次,第二个进入后,第一个立马关闭时,不会立即删除。而是等第二个进入流程完毕后,再删除房间。

image.png

写在最后

我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。