我报名参与金石计划1期挑战——分割10万奖池,这是我的第2篇文章,点击检查活动详情。
大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》,是个网页,能够很便利的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技能便是WebSocket,我会共享怎么用Go完成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: 整理房间时,也整理该房间的锁
考虑:这样改能够吗?
回忆一下整理房间的逻辑:
抛出一个问题:我能够直接修正下面这段逻辑吗?
原逻辑:
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
}
你考虑下。结合进入房间的逻辑:
剖析
答案是不能够。
进入房间时,需要拜访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
能够吗?
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
}
}
roomMutex.Unlock()
你考虑一下。记得看看进入房间的逻辑。
深度剖析
答案是不能够。
这是一个典型的死锁事例。
凡是你在代码里有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。就能够写出下面的代码:
你或许会猎奇,为什么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
写在最终
我是HullQin,独立开发了《联机桌游合集》,是个网页,能够很便利的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参与Game Jam 2022。喜爱能够重视我噢~我有空了会共享做游戏的相关技能,会在这2个专栏里共享:《教你做小游戏》、《极致用户体会》。