大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》​,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,我会分享如何用Go实现WebSocket服务,文章写在专栏《Go WebSocket》里,关注专栏跟我一起学习吧!


但是到目前为止,我们的多房间聊天室还是不够完美,存在2个问题:

  1. ​roomMutexes​​是一个全局map,当房间被清理时,这个map里依然保存着key为roomId的sync.Mutex。随着时间延长,这个map会越来越大……并且大多数都用不到了。如果有人想恶意攻击你的系统,只需要连续不断的访问不同的房间号,那么你系统内存会被打爆的。总之这个系统不够持久、也比较脆弱。
  2. ​house​​​变量是一个全局map,而Go中map是不支持并发写的。对某个map执行设置key或删除key,都不是原子的,当某个goroutine设置/删除key做了一半,CPU被切到另一个goroutine,去执行设置/删除同一map的某个key,就会报错​​fatal error: concurrent map writes​​​。因此如果map存在大量并发写时,会导致出错概率提高。这种情况下,要用sync.map。在本文章的场景下,写入map有如下场景:用户进了某个新房间(没人的房间)、用户离开了某个只剩一人的房间。并发量大的场景下,是很有可能出现同时有n个用户进入n个不同的新房间的。所以结论是,house应该使用sync.map,不能用map。(但是​​roomMutexes​​这个map没有问题,因为不涉及并发写,在写入前是加了全局锁的)

跟着走

本文代码起点:github.com/HullQin/go-…

本文是基于前六篇文章的,所以至上篇文章,代码已经更新到这个commit了,你可以Pull下来跟着一起思考、学习、修改。

先解决问题2: 替换house为sync.map

注意sync.map是没有类型的,所以读取后需要强制类型转换。

关注这个commit: github.com/HullQin/go-…

[Go WebSocket] 多房间的聊天室(七)删除房间时,顺便清除房间锁_死锁

再解决问题1: 清理房间时,也清理该房间的锁

思考:这样改可以吗?

回顾一下清理房间的逻辑:

[Go WebSocket] 多房间的聊天室(七)删除房间时,顺便清除房间锁_Go_02

抛出一个问题:我可以直接修改下面这段逻辑吗?

原逻辑:

if len(h.clients) == 0 {
house.Delete(h.roomId)
roomMutexes[h.roomId].Unlock()
return

为了清理房间锁,改为这样,可以吗?

if len(h.clients) == 0 {
house.Delete(h.roomId)
roomMutexes[h.roomId].Unlock()
delete(roomMutexes, h.roomId)
return

你思考下。结合进入房间的逻辑:

[Go WebSocket] 多房间的聊天室(七)删除房间时,顺便清除房间锁_聊天室_03

分析

答案是不可以。

进入房间时,需要访问​​roomMutexes​​,我们设置了全局锁。清理房间时,我们设置的锁仅仅是房间维度的锁。二者没有冲突,是有机会并发的。一旦并发,就可能导致各种问题,例如:

  1. 一个goroutine准备清理房间时,即将执行​​delete(roomMutexes, h.roomId)​​​时,恰好调度另一个goroutine,执行​​roomMutex, ok := roomMutexes[roomId]​​​,使用了即将被删掉的锁。然后这个锁从roomMutexes这个map里删掉了。随后又有一个进入该房间的人,执行​​roomMutexes[roomId] = new(sync.Mutex)​​新生成了锁。那么同一房间的2个人进入同一个房间,但是使用的是2把不同的锁。这会导致其它莫名其妙的问题。
  2. ​roomMutexes​​​是普通的map,不是sync.map,所以并发写、删会有冲突。但是这个问题不致命,因为解决该问题,只要设置​​roomMutexes​​为sync.map即可。但是即使这样,问题1也无法避免。

所以,教训就是:我们删除roomMutexes中的房间锁时,必须要先设置全局锁,再进行删除。

这样保证了「进入房间时获得锁」和「离开房间时删除锁」,过程都是原子的,就没并发冲突。

深度思考:这样可以吗?

如果简单的在delete这个房间锁前,获取一下这个全局锁​​mutexForRoomMutexes​​可以吗?

select {
case client := <-h.unregister:
roomMutex := roomMutexes[h.roomId]
roomMutex.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
if len(h.clients) == 0 {
house.Delete(h.roomId)
mutexForRoomMutexes.Lock()
delete(roomMutexes, h.roomId)
roomMutex.Unlock()
mutexForRoomMutexes.Unlock()
return

你思考一下。记得看看进入房间的逻辑。

深度分析

答案是不可以。

这是一个典型的死锁案例。

但凡你在代码里有2把锁(不论大锁还是小锁),我们称为锁A和锁B。如果你有2个goroutine分别有这种逻辑:

goroutine1 伪代码:
获得锁A
获得锁B
释放锁B
释放锁A

goroutine2 伪代码:
获得锁B
获得锁A
释放锁A
释放锁B

那么你大概率会遇到死锁问题。2个goroutine都卡住了,程序没有响应。

在上面这段解决方案的代码逻辑里,大锁和房间锁,分别就是锁A和锁B。进入房间的逻辑,相当于goroutine1,删除房间的逻辑,相当于goroutine2。

解决这种模式死锁的典型方案:始终按照顺序获得锁A和锁B。

如果所有goroutine都是这样写:

goroutine 伪代码:
获得锁A
获得锁B
释放锁B或A
释放锁A或B

就避免了死锁问题。我们把大锁当作锁A,房间锁当作锁B。就可以写出下面的代码:

[Go WebSocket] 多房间的聊天室(七)删除房间时,顺便清除房间锁_后端_04

你也许会好奇,为什么49行要用​​roomMutex.TryLock()​​?

​TryLock​​​:尝试获取Mutex,如果当前Mutex没有被Lock,就相当于​​Lock()​​并返回true,否则,返回false,并继续执行下面的逻辑。

主要是因为我们是先​​roomMutex.Unlock()​​​再​​mutexForRoomMutexes.Lock()​​​。在这两行之间,goroutine没有获得任何锁,有可能此时其它人进入了该房间,抢先获得了​​mutexForRoomMutexes​​全局锁,并且加入了房间。有2种情况:

  1. 进入房间的人,释放​​mutexForRoomMutexes​​​全局锁后,还没释放​​roomMutex​​​房间锁(因为进入房间逻辑是先释放前者,后释放后者)。此时​​roomMutex.TryLock()​​获取房间锁失败,不再执行清除房间锁逻辑。这把锁可以被新创建的房间复用。
  2. 进入房间的人,释放了​​mutexForRoomMutexes​​​全局锁,并且也释放了​​roomMutex​​​房间锁(即serveWs逻辑也执行完了),此时该人确实进入了房间,​​h.clients​​​不再是0了。这时​​roomMutex.TryLock()​​​确实能获得成功锁,我们就判断一下​​h.clients​​长度,如果为0,才删除这个房间锁,非0就什么都不做。最后释放这个房间锁。

源码

仓库地址:github.com/HullQin/go-…

关注这2个commit:

  • replace house map with sync.map
  • delete room mutex when deleting room