乐趣区

关于后端:Go-WebSocket-多房间的聊天室四黑天鹅事件

我是 HullQin,公众号 线下团聚游戏 的作者(欢送关注公众号,发送加微信,交个敌人),转发本文前需取得作者 HullQin 受权。我独立开发了《联机桌游合集》,是个网页,能够很不便的跟敌人联机玩斗地主、五子棋等游戏,不免费没广告。还开发了《Dice Crush》加入 Game Jam 2022。喜爱能够关注我 HullQin 噢~我有空了会分享做游戏的相干技术。

背景

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

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

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

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

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

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

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

如果你没浏览下面的文章,肯定要先看一下,因为这篇文章更简单,如果你不弄懂下面几篇,这篇可能跟不上节奏噢。

黑天鹅事件

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

我用连线,表明了 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。而去此时这个页面和上一步关上的页面无奈失常通信。

解决方案(易犯错)

加锁

通过给 househub.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 噢~我有空了会分享做游戏的相干技术。

退出移动版