大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,我会分享如何用Go实现WebSocket服务,文章写在专栏《Go WebSocket》里,关注专栏跟我一起学习吧!
但是到目前为止,我们的多房间聊天室还是不够完美,存在2个问题:
-
roomMutexes
是一个全局map,当房间被清理时,这个map里依然保存着key为roomId的sync.Mutex。随着时间延长,这个map会越来越大……并且大多数都用不到了。如果有人想恶意攻击你的系统,只需要连续不断的访问不同的房间号,那么你系统内存会被打爆的。总之这个系统不够持久、也比较脆弱。 -
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-…
再解决问题1: 清理房间时,也清理该房间的锁
思考:这样改可以吗?
回顾一下清理房间的逻辑:
抛出一个问题:我可以直接修改下面这段逻辑吗?
原逻辑:
为了清理房间锁,改为这样,可以吗?
你思考下。结合进入房间的逻辑:
分析
答案是不可以。
进入房间时,需要访问roomMutexes
,我们设置了全局锁。清理房间时,我们设置的锁仅仅是房间维度的锁。二者没有冲突,是有机会并发的。一旦并发,就可能导致各种问题,例如:
- 一个goroutine准备清理房间时,即将执行
delete(roomMutexes, h.roomId)
时,恰好调度另一个goroutine,执行roomMutex, ok := roomMutexes[roomId]
,使用了即将被删掉的锁。然后这个锁从roomMutexes这个map里删掉了。随后又有一个进入该房间的人,执行roomMutexes[roomId] = new(sync.Mutex)
新生成了锁。那么同一房间的2个人进入同一个房间,但是使用的是2把不同的锁。这会导致其它莫名其妙的问题。 -
roomMutexes
是普通的map,不是sync.map,所以并发写、删会有冲突。但是这个问题不致命,因为解决该问题,只要设置roomMutexes
为sync.map即可。但是即使这样,问题1也无法避免。
所以,教训就是:我们删除roomMutexes
中的房间锁时,必须要先设置全局锁,再进行删除。
这样保证了「进入房间时获得锁」和「离开房间时删除锁」,过程都是原子的,就没并发冲突。
深度思考:这样可以吗?
如果简单的在delete这个房间锁前,获取一下这个全局锁mutexForRoomMutexes
可以吗?
你思考一下。记得看看进入房间的逻辑。
深度分析
答案是不可以。
这是一个典型的死锁案例。
但凡你在代码里有2把锁(不论大锁还是小锁),我们称为锁A和锁B。如果你有2个goroutine分别有这种逻辑:
那么你大概率会遇到死锁问题。2个goroutine都卡住了,程序没有响应。
在上面这段解决方案的代码逻辑里,大锁和房间锁,分别就是锁A和锁B。进入房间的逻辑,相当于goroutine1,删除房间的逻辑,相当于goroutine2。
解决这种模式死锁的典型方案:始终按照顺序获得锁A和锁B。
如果所有goroutine都是这样写:
就避免了死锁问题。我们把大锁当作锁A,房间锁当作锁B。就可以写出下面的代码:
你也许会好奇,为什么49行要用roomMutex.TryLock()
?
TryLock
:尝试获取Mutex,如果当前Mutex没有被Lock,就相当于Lock()
并返回true,否则,返回false,并继续执行下面的逻辑。
主要是因为我们是先roomMutex.Unlock()
再mutexForRoomMutexes.Lock()
。在这两行之间,goroutine没有获得任何锁,有可能此时其它人进入了该房间,抢先获得了mutexForRoomMutexes
全局锁,并且加入了房间。有2种情况:
- 进入房间的人,释放
mutexForRoomMutexes
全局锁后,还没释放roomMutex
房间锁(因为进入房间逻辑是先释放前者,后释放后者)。此时roomMutex.TryLock()
获取房间锁失败,不再执行清除房间锁逻辑。这把锁可以被新创建的房间复用。 - 进入房间的人,释放了
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