[Go WebSocket] 多房間的聊天室(七)刪除房間時,順便清除房間鎖
theme: devui-blue
我報名參加金石計劃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-websocket-examples
本文是基於前六篇文章的,所以至上篇文章,程式碼已經更新到這個commit了,你可以Pull下來跟著一起思考、學習、修改。
先解決問題2: 替換house為sync.map
注意sync.map是沒有型別的,所以讀取後需要強制型別轉換。
關注這個commit: github.com/HullQin/go-websocket-examples
再解決問題1: 清理房間時,也清理該房間的鎖
思考:這樣改可以嗎?
回顧一下清理房間的邏輯:
丟擲一個問題:我可以直接修改下面這段邏輯嗎?
原邏輯:
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
}
你思考下。結合進入房間的邏輯:
分析
答案是不可以。
進入房間時,需要訪問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
可以嗎?
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。就可以寫出下面的程式碼:
你也許會好奇,為什麼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-websocket-examples
關注這2個commit:
寫在最後
我是HullQin,獨立開發了《聯機桌遊合集》,是個網頁,可以很方便的跟朋友聯機玩鬥地主、五子棋等遊戲,不收費無廣告。還獨立開發了《合成大西瓜重製版》。還開發了《Dice Crush》參加Game Jam 2022。喜歡可以關注我噢~我有空了會分享做遊戲的相關技術,會在這2個專欄裡分享:《教你做小遊戲》、《極致使用者體驗》。
- 一定給Button加上這個CSS,否則在使用者設定的字型或字號下,文案就換行了!
- [Go WebSocket] 多房間的聊天室(七)刪除房間時,順便清除房間鎖
- 一行程式碼,讓網頁變為黑白配色
- 聯機象棋釋出!開啟URL就能聯機對戰!觀戰!單機演練!分享殘局!
- 開發個「英雄殺記牌器」
- [教你做小遊戲] 太捲了!開發象棋,為了減少40%儲存空間,我學了下Huffman Coding
- [教你做小遊戲] 極致壓縮:用2至5位二進位制表示17種可能性
- [教你做小遊戲] 車炮能移動17個位置,針對90種出發點,如何建立0-16和目標點的對映?
- 不用React Vue,只用原生JS,如何開發單頁面應用(SPA)?
- 火爆全網的 Evil.js 原始碼解讀
- 在網際網路,摸爬滾打了幾年,我悟了。面對如今經濟形勢,普通打工人如何應對?
- [Go WebSocket] 你的第二個Go WebSocket服務: 聊天室
- 我們用48h,合作創造了一款Web遊戲:Dice Crush,參加國際賽事
- [JS真好玩] 掘金前端你好:作者榜出bug啦,我們一起看怎麼修。順便分享創意技巧:改EventListener
- [教你做小遊戲] 展示鬥地主撲克牌,支援按出牌規則排序!支援按大小排序!
- [JS入門到進階] 手寫裁剪圖片的工具,並部署。一鍵裁剪掘金文章封面
- [JS入門到進階] 手寫解析URL引數的工具,並部署。用起來又快又爽!
- 你必須要會uvloop!讓Python asyncio非同步程式設計效能直逼Go協程效能
- [JS入門到進階] 7條關於 async await 的使用口訣,新學 async await?背10遍,以後要考!快收藏
- [JS真好玩] 大招!用JS找到:哪 個 小 壞 蛋 給 我 連 點 2 次 贊 ?