[Go WebSocket] 多房間的聊天室(七)刪除房間時,順便清除房間鎖

語言: CN / TW / HK

theme: devui-blue

我報名參加金石計劃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-websocket-examples

本文是基於前六篇文章的,所以至上篇文章,程式碼已經更新到這個commit了,你可以Pull下來跟著一起思考、學習、修改。

先解決問題2: 替換house為sync.map

注意sync.map是沒有型別的,所以讀取後需要強制型別轉換。

關注這個commit: github.com/HullQin/go-websocket-examples

image.png

再解決問題1: 清理房間時,也清理該房間的鎖

思考:這樣改可以嗎?

回顧一下清理房間的邏輯:

image.png

丟擲一個問題:我可以直接修改下面這段邏輯嗎?

原邏輯:

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

為了清理房間鎖,改為這樣,可以嗎?

go 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可以嗎?

go 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分別有這種邏輯:

```txt goroutine1 虛擬碼: 獲得鎖A 獲得鎖B 釋放鎖B 釋放鎖A

goroutine2 虛擬碼: 獲得鎖B 獲得鎖A 釋放鎖A 釋放鎖B ```

那麼你大概率會遇到死鎖問題。2個goroutine都卡住了,程式沒有響應。

在上面這段解決方案的程式碼邏輯裡,大鎖和房間鎖,分別就是鎖A和鎖B。進入房間的邏輯,相當於goroutine1,刪除房間的邏輯,相當於goroutine2。

解決這種模式死鎖的典型方案:始終按照順序獲得鎖A和鎖B。

如果所有goroutine都是這樣寫:

txt 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-websocket-examples

關注這2個commit:

寫在最後

我是HullQin,獨立開發了《聯機桌遊合集》,是個網頁,可以很方便的跟朋友聯機玩鬥地主、五子棋等遊戲,不收費無廣告。還獨立開發了《合成大西瓜重製版》。還開發了《Dice Crush》參加Game Jam 2022。喜歡可以關注我噢~我有空了會分享做遊戲的相關技術,會在這2個專欄裡分享:《教你做小遊戲》《極致使用者體驗》

「其他文章」