[Ir a WebSocket] Sala de chat de varias salas (7) Al eliminar una sala, borre el bloqueo de la sala por cierto

Me inscribí para participar en el primer desafío del Proyecto Golden Stone: compartir el premio acumulado de 100 000. Este es mi segundo artículo. Haz clic para ver los detalles del evento .

Hola a todos, soy el autor de la cuenta oficial "Offline Party Games" y he desarrollado "Online Board Game Collection" . La tecnología central es WebSocket. Compartiré cómo usar Go para implementar los servicios de WebSocket. El artículo está escrito en la columna "Go WebSocket" . ¡Siga la columna y aprenda conmigo!

antecedentes

En la columna "Ir a WebSocket" , hay algunos artículos previos:

"Sala de chat de una sola sala" describe cómo implementar una sala de chat de una sola sala.

"Pensamiento de sala de chat de varias salas (1)" , introduce la idea de realizar una sala de chat de varias salas.

"Implementación del código de la sala de chat de varias salas (2)" , presenta el código para implementar una sala de chat de varias salas.

"Sala de chat multisala (3) limpieza automática de salas no tripuladas" , presenta cómo limpiar salas no tripuladas y evitar el problema del crecimiento infinito de la memoria.

"Evento de cisne negro de la sala de chat de varias salas (cuatro)" , presenta cómo evitar el problema de la competencia de recursos causada por la concurrencia, que se resuelve mediante el bloqueo pesimista.

"Sala de chat de varias salas (5) Use varios bloqueos pequeños en lugar de bloqueos grandes para mejorar la eficiencia" , presenta que al dividir un bloqueo grande global en varios bloqueos pequeños, se mejora la eficiencia de la concurrencia.

"Sala de chat multisala (6) ¿Por qué debería estar bloqueada? ¿No puedes bloquearlo? , introduce la necesidad y la corrección del bloqueo.

Pero hasta ahora, nuestra sala de chat multisala no es perfecta, hay 2 problemas:

  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:

写在最后

Mi nombre es HullQin, y he desarrollado de forma independiente "Colección de juegos de mesa en línea" . Es una página web donde puedes jugar fácilmente a Douzhuzhu, Gobang y otros juegos en línea con tus amigos, de forma gratuita y sin anuncios. También desarrolló de forma independiente "Synthetic Big Watermelon Remake" . También desarrolló Dice Crush para Game Jam 2022. Si te gusta, puedes seguirme ~ Cuando tenga tiempo, compartiré las tecnologías relacionadas con la creación de juegos, y las compartiré en estas dos columnas: "Enseñarte a hacer juegos pequeños" y "Experiencia de usuario extrema" .

Supongo que te gusta

Origin juejin.im/post/7146873460956856327
Recomendado
Clasificación