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

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,我会分享如何用Go实现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-…

image.png

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

思考:这样改可以吗?

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

image.png

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

原逻辑:

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
}
复制代码

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

image.png

分析

答案是不可以。

进入房间时,需要访问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
      }
   }
   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。就可以写出下面的代码:

image.png

你也许会好奇,为什么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:

写在最后

我是HullQin,独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:《教你做小游戏》《极致用户体验》

猜你喜欢

转载自juejin.im/post/7146873460956856327